file_context_picker.rs

  1use std::path::Path;
  2use std::sync::atomic::AtomicBool;
  3use std::sync::Arc;
  4
  5use file_icons::FileIcons;
  6use fuzzy::PathMatch;
  7use gpui::{AppContext, DismissEvent, FocusHandle, FocusableView, Task, View, WeakModel, WeakView};
  8use picker::{Picker, PickerDelegate};
  9use project::{PathMatchCandidateSet, ProjectPath, WorktreeId};
 10use ui::{prelude::*, ListItem, Tooltip};
 11use util::ResultExt as _;
 12use workspace::Workspace;
 13
 14use crate::context_picker::{ConfirmBehavior, ContextPicker};
 15use crate::context_store::{ContextStore, FileInclusion};
 16
 17pub struct FileContextPicker {
 18    picker: View<Picker<FileContextPickerDelegate>>,
 19}
 20
 21impl FileContextPicker {
 22    pub fn new(
 23        context_picker: WeakView<ContextPicker>,
 24        workspace: WeakView<Workspace>,
 25        context_store: WeakModel<ContextStore>,
 26        confirm_behavior: ConfirmBehavior,
 27        cx: &mut ViewContext<Self>,
 28    ) -> Self {
 29        let delegate = FileContextPickerDelegate::new(
 30            context_picker,
 31            workspace,
 32            context_store,
 33            confirm_behavior,
 34        );
 35        let picker = cx.new_view(|cx| Picker::uniform_list(delegate, cx));
 36
 37        Self { picker }
 38    }
 39}
 40
 41impl FocusableView for FileContextPicker {
 42    fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
 43        self.picker.focus_handle(cx)
 44    }
 45}
 46
 47impl Render for FileContextPicker {
 48    fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
 49        self.picker.clone()
 50    }
 51}
 52
 53pub struct FileContextPickerDelegate {
 54    context_picker: WeakView<ContextPicker>,
 55    workspace: WeakView<Workspace>,
 56    context_store: WeakModel<ContextStore>,
 57    confirm_behavior: ConfirmBehavior,
 58    matches: Vec<PathMatch>,
 59    selected_index: usize,
 60}
 61
 62impl FileContextPickerDelegate {
 63    pub fn new(
 64        context_picker: WeakView<ContextPicker>,
 65        workspace: WeakView<Workspace>,
 66        context_store: WeakModel<ContextStore>,
 67        confirm_behavior: ConfirmBehavior,
 68    ) -> Self {
 69        Self {
 70            context_picker,
 71            workspace,
 72            context_store,
 73            confirm_behavior,
 74            matches: Vec::new(),
 75            selected_index: 0,
 76        }
 77    }
 78
 79    fn search(
 80        &mut self,
 81        query: String,
 82        cancellation_flag: Arc<AtomicBool>,
 83        workspace: &View<Workspace>,
 84        cx: &mut ViewContext<Picker<Self>>,
 85    ) -> Task<Vec<PathMatch>> {
 86        if query.is_empty() {
 87            let workspace = workspace.read(cx);
 88            let project = workspace.project().read(cx);
 89            let recent_matches = workspace
 90                .recent_navigation_history(Some(10), cx)
 91                .into_iter()
 92                .filter_map(|(project_path, _)| {
 93                    let worktree = project.worktree_for_id(project_path.worktree_id, cx)?;
 94                    Some(PathMatch {
 95                        score: 0.,
 96                        positions: Vec::new(),
 97                        worktree_id: project_path.worktree_id.to_usize(),
 98                        path: project_path.path,
 99                        path_prefix: worktree.read(cx).root_name().into(),
100                        distance_to_relative_ancestor: 0,
101                        is_dir: false,
102                    })
103                });
104
105            let file_matches = project.worktrees(cx).flat_map(|worktree| {
106                let worktree = worktree.read(cx);
107                let path_prefix: Arc<str> = worktree.root_name().into();
108                worktree.files(true, 0).map(move |entry| PathMatch {
109                    score: 0.,
110                    positions: Vec::new(),
111                    worktree_id: worktree.id().to_usize(),
112                    path: entry.path.clone(),
113                    path_prefix: path_prefix.clone(),
114                    distance_to_relative_ancestor: 0,
115                    is_dir: false,
116                })
117            });
118
119            Task::ready(recent_matches.chain(file_matches).collect())
120        } else {
121            let worktrees = workspace.read(cx).visible_worktrees(cx).collect::<Vec<_>>();
122            let candidate_sets = worktrees
123                .into_iter()
124                .map(|worktree| {
125                    let worktree = worktree.read(cx);
126
127                    PathMatchCandidateSet {
128                        snapshot: worktree.snapshot(),
129                        include_ignored: worktree
130                            .root_entry()
131                            .map_or(false, |entry| entry.is_ignored),
132                        include_root_name: true,
133                        candidates: project::Candidates::Files,
134                    }
135                })
136                .collect::<Vec<_>>();
137
138            let executor = cx.background_executor().clone();
139            cx.foreground_executor().spawn(async move {
140                fuzzy::match_path_sets(
141                    candidate_sets.as_slice(),
142                    query.as_str(),
143                    None,
144                    false,
145                    100,
146                    &cancellation_flag,
147                    executor,
148                )
149                .await
150            })
151        }
152    }
153}
154
155impl PickerDelegate for FileContextPickerDelegate {
156    type ListItem = ListItem;
157
158    fn match_count(&self) -> usize {
159        self.matches.len()
160    }
161
162    fn selected_index(&self) -> usize {
163        self.selected_index
164    }
165
166    fn set_selected_index(&mut self, ix: usize, _cx: &mut ViewContext<Picker<Self>>) {
167        self.selected_index = ix;
168    }
169
170    fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc<str> {
171        "Search files…".into()
172    }
173
174    fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()> {
175        let Some(workspace) = self.workspace.upgrade() else {
176            return Task::ready(());
177        };
178
179        let search_task = self.search(query, Arc::<AtomicBool>::default(), &workspace, cx);
180
181        cx.spawn(|this, mut cx| async move {
182            // TODO: This should be probably be run in the background.
183            let paths = search_task.await;
184
185            this.update(&mut cx, |this, _cx| {
186                this.delegate.matches = paths;
187            })
188            .log_err();
189        })
190    }
191
192    fn confirm(&mut self, _secondary: bool, cx: &mut ViewContext<Picker<Self>>) {
193        let Some(mat) = self.matches.get(self.selected_index) else {
194            return;
195        };
196
197        let project_path = ProjectPath {
198            worktree_id: WorktreeId::from_usize(mat.worktree_id),
199            path: mat.path.clone(),
200        };
201
202        let Some(task) = self
203            .context_store
204            .update(cx, |context_store, cx| {
205                context_store.add_file_from_path(project_path, cx)
206            })
207            .ok()
208        else {
209            return;
210        };
211
212        let workspace = self.workspace.clone();
213        let confirm_behavior = self.confirm_behavior;
214        cx.spawn(|this, mut cx| async move {
215            match task.await {
216                Ok(()) => {
217                    this.update(&mut cx, |this, cx| match confirm_behavior {
218                        ConfirmBehavior::KeepOpen => {}
219                        ConfirmBehavior::Close => this.delegate.dismissed(cx),
220                    })?;
221                }
222                Err(err) => {
223                    let Some(workspace) = workspace.upgrade() else {
224                        return anyhow::Ok(());
225                    };
226
227                    workspace.update(&mut cx, |workspace, cx| {
228                        workspace.show_error(&err, cx);
229                    })?;
230                }
231            }
232
233            anyhow::Ok(())
234        })
235        .detach_and_log_err(cx);
236    }
237
238    fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
239        self.context_picker
240            .update(cx, |this, cx| {
241                this.reset_mode();
242                cx.emit(DismissEvent);
243            })
244            .ok();
245    }
246
247    fn render_match(
248        &self,
249        ix: usize,
250        selected: bool,
251        cx: &mut ViewContext<Picker<Self>>,
252    ) -> Option<Self::ListItem> {
253        let path_match = &self.matches[ix];
254
255        let (file_name, directory) = if path_match.path.as_ref() == Path::new("") {
256            (SharedString::from(path_match.path_prefix.clone()), None)
257        } else {
258            let file_name = path_match
259                .path
260                .file_name()
261                .unwrap_or_default()
262                .to_string_lossy()
263                .to_string()
264                .into();
265
266            let mut directory = format!("{}/", path_match.path_prefix);
267            if let Some(parent) = path_match
268                .path
269                .parent()
270                .filter(|parent| parent != &Path::new(""))
271            {
272                directory.push_str(&parent.to_string_lossy());
273                directory.push('/');
274            }
275
276            (file_name, Some(directory))
277        };
278
279        let added = self.context_store.upgrade().and_then(|context_store| {
280            context_store
281                .read(cx)
282                .will_include_file_path(&path_match.path, cx)
283        });
284
285        let file_icon = FileIcons::get_icon(&path_match.path.clone(), cx)
286            .map(Icon::from_path)
287            .unwrap_or_else(|| Icon::new(IconName::File));
288
289        Some(
290            ListItem::new(ix)
291                .inset(true)
292                .toggle_state(selected)
293                .child(
294                    h_flex()
295                        .gap_2()
296                        .child(file_icon.size(IconSize::Small))
297                        .child(Label::new(file_name))
298                        .children(directory.map(|directory| {
299                            Label::new(directory)
300                                .size(LabelSize::Small)
301                                .color(Color::Muted)
302                        })),
303                )
304                .when_some(added, |el, added| match added {
305                    FileInclusion::Direct(_) => el.end_slot(
306                        h_flex()
307                            .gap_1()
308                            .child(
309                                Icon::new(IconName::Check)
310                                    .size(IconSize::Small)
311                                    .color(Color::Success),
312                            )
313                            .child(Label::new("Added").size(LabelSize::Small)),
314                    ),
315                    FileInclusion::InDirectory(dir_name) => {
316                        let dir_name = dir_name.to_string_lossy().into_owned();
317
318                        el.end_slot(
319                            h_flex()
320                                .gap_1()
321                                .child(
322                                    Icon::new(IconName::Check)
323                                        .size(IconSize::Small)
324                                        .color(Color::Success),
325                                )
326                                .child(Label::new("Included").size(LabelSize::Small)),
327                        )
328                        .tooltip(move |cx| Tooltip::text(format!("in {dir_name}"), cx))
329                    }
330                }),
331        )
332    }
333}