file_context_picker.rs

  1use std::fmt::Write as _;
  2use std::ops::RangeInclusive;
  3use std::path::{Path, PathBuf};
  4use std::sync::atomic::AtomicBool;
  5use std::sync::Arc;
  6
  7use fuzzy::PathMatch;
  8use gpui::{AppContext, DismissEvent, FocusHandle, FocusableView, Task, View, WeakModel, WeakView};
  9use picker::{Picker, PickerDelegate};
 10use project::{PathMatchCandidateSet, WorktreeId};
 11use ui::{prelude::*, ListItem};
 12use util::ResultExt as _;
 13use workspace::Workspace;
 14
 15use crate::context::ContextKind;
 16use crate::context_picker::ContextPicker;
 17use crate::context_store::ContextStore;
 18
 19pub struct FileContextPicker {
 20    picker: View<Picker<FileContextPickerDelegate>>,
 21}
 22
 23impl FileContextPicker {
 24    pub fn new(
 25        context_picker: WeakView<ContextPicker>,
 26        workspace: WeakView<Workspace>,
 27        context_store: WeakModel<ContextStore>,
 28        cx: &mut ViewContext<Self>,
 29    ) -> Self {
 30        let delegate = FileContextPickerDelegate::new(context_picker, workspace, context_store);
 31        let picker = cx.new_view(|cx| Picker::uniform_list(delegate, cx));
 32
 33        Self { picker }
 34    }
 35}
 36
 37impl FocusableView for FileContextPicker {
 38    fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
 39        self.picker.focus_handle(cx)
 40    }
 41}
 42
 43impl Render for FileContextPicker {
 44    fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
 45        self.picker.clone()
 46    }
 47}
 48
 49pub struct FileContextPickerDelegate {
 50    context_picker: WeakView<ContextPicker>,
 51    workspace: WeakView<Workspace>,
 52    context_store: WeakModel<ContextStore>,
 53    matches: Vec<PathMatch>,
 54    selected_index: usize,
 55}
 56
 57impl FileContextPickerDelegate {
 58    pub fn new(
 59        context_picker: WeakView<ContextPicker>,
 60        workspace: WeakView<Workspace>,
 61        context_store: WeakModel<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    fn search(
 73        &mut self,
 74        query: String,
 75        cancellation_flag: Arc<AtomicBool>,
 76        workspace: &View<Workspace>,
 77        cx: &mut ViewContext<Picker<Self>>,
 78    ) -> Task<Vec<PathMatch>> {
 79        if query.is_empty() {
 80            let workspace = workspace.read(cx);
 81            let project = workspace.project().read(cx);
 82            let entries = workspace.recent_navigation_history(Some(10), cx);
 83
 84            let entries = entries
 85                .into_iter()
 86                .map(|entries| entries.0)
 87                .chain(project.worktrees(cx).flat_map(|worktree| {
 88                    let worktree = worktree.read(cx);
 89                    let id = worktree.id();
 90                    worktree
 91                        .child_entries(Path::new(""))
 92                        .filter(|entry| entry.kind.is_file())
 93                        .map(move |entry| project::ProjectPath {
 94                            worktree_id: id,
 95                            path: entry.path.clone(),
 96                        })
 97                }))
 98                .collect::<Vec<_>>();
 99
100            let path_prefix: Arc<str> = Arc::default();
101            Task::ready(
102                entries
103                    .into_iter()
104                    .filter_map(|entry| {
105                        let worktree = project.worktree_for_id(entry.worktree_id, cx)?;
106                        let mut full_path = PathBuf::from(worktree.read(cx).root_name());
107                        full_path.push(&entry.path);
108                        Some(PathMatch {
109                            score: 0.,
110                            positions: Vec::new(),
111                            worktree_id: entry.worktree_id.to_usize(),
112                            path: full_path.into(),
113                            path_prefix: path_prefix.clone(),
114                            distance_to_relative_ancestor: 0,
115                            is_dir: false,
116                        })
117                    })
118                    .collect(),
119            )
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 mat = &self.matches[self.selected_index];
194
195        let workspace = self.workspace.clone();
196        let Some(project) = workspace
197            .upgrade()
198            .map(|workspace| workspace.read(cx).project().clone())
199        else {
200            return;
201        };
202        let path = mat.path.clone();
203        let worktree_id = WorktreeId::from_usize(mat.worktree_id);
204        cx.spawn(|this, mut cx| async move {
205            let Some(open_buffer_task) = project
206                .update(&mut cx, |project, cx| {
207                    project.open_buffer((worktree_id, path.clone()), cx)
208                })
209                .ok()
210            else {
211                return anyhow::Ok(());
212            };
213
214            let buffer = open_buffer_task.await?;
215
216            this.update(&mut cx, |this, cx| {
217                this.delegate.context_store.update(cx, |context_store, cx| {
218                    let mut text = String::new();
219                    text.push_str(&codeblock_fence_for_path(Some(&path), None));
220                    text.push_str(&buffer.read(cx).text());
221                    if !text.ends_with('\n') {
222                        text.push('\n');
223                    }
224
225                    text.push_str("```\n");
226
227                    context_store.insert_context(
228                        ContextKind::File,
229                        path.to_string_lossy().to_string(),
230                        text,
231                    );
232                })
233            })??;
234
235            anyhow::Ok(())
236        })
237        .detach_and_log_err(cx);
238    }
239
240    fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
241        self.context_picker
242            .update(cx, |this, cx| {
243                this.reset_mode();
244                cx.emit(DismissEvent);
245            })
246            .ok();
247    }
248
249    fn render_match(
250        &self,
251        ix: usize,
252        selected: bool,
253        _cx: &mut ViewContext<Picker<Self>>,
254    ) -> Option<Self::ListItem> {
255        let path_match = &self.matches[ix];
256        let file_name = path_match
257            .path
258            .file_name()
259            .unwrap_or_default()
260            .to_string_lossy()
261            .to_string();
262        let directory = path_match
263            .path
264            .parent()
265            .map(|directory| format!("{}/", directory.to_string_lossy()));
266
267        Some(
268            ListItem::new(ix).inset(true).toggle_state(selected).child(
269                h_flex()
270                    .gap_2()
271                    .child(Label::new(file_name))
272                    .children(directory.map(|directory| {
273                        Label::new(directory)
274                            .size(LabelSize::Small)
275                            .color(Color::Muted)
276                    })),
277            ),
278        )
279    }
280}
281
282fn codeblock_fence_for_path(path: Option<&Path>, row_range: Option<RangeInclusive<u32>>) -> String {
283    let mut text = String::new();
284    write!(text, "```").unwrap();
285
286    if let Some(path) = path {
287        if let Some(extension) = path.extension().and_then(|ext| ext.to_str()) {
288            write!(text, "{} ", extension).unwrap();
289        }
290
291        write!(text, "{}", path.display()).unwrap();
292    } else {
293        write!(text, "untitled").unwrap();
294    }
295
296    if let Some(row_range) = row_range {
297        write!(text, ":{}-{}", row_range.start() + 1, row_range.end() + 1).unwrap();
298    }
299
300    text.push('\n');
301    text
302}