file_context_picker.rs

  1use std::fmt::Write as _;
  2use std::ops::RangeInclusive;
  3use std::path::Path;
  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 recent_matches = workspace
 83                .recent_navigation_history(Some(10), cx)
 84                .into_iter()
 85                .filter_map(|(project_path, _)| {
 86                    let worktree = project.worktree_for_id(project_path.worktree_id, cx)?;
 87                    Some(PathMatch {
 88                        score: 0.,
 89                        positions: Vec::new(),
 90                        worktree_id: project_path.worktree_id.to_usize(),
 91                        path: project_path.path,
 92                        path_prefix: worktree.read(cx).root_name().into(),
 93                        distance_to_relative_ancestor: 0,
 94                        is_dir: false,
 95                    })
 96                });
 97
 98            let file_matches = project.worktrees(cx).flat_map(|worktree| {
 99                let worktree = worktree.read(cx);
100                let path_prefix: Arc<str> = worktree.root_name().into();
101                worktree.files(true, 0).map(move |entry| PathMatch {
102                    score: 0.,
103                    positions: Vec::new(),
104                    worktree_id: worktree.id().to_usize(),
105                    path: entry.path.clone(),
106                    path_prefix: path_prefix.clone(),
107                    distance_to_relative_ancestor: 0,
108                    is_dir: false,
109                })
110            });
111
112            Task::ready(recent_matches.chain(file_matches).collect())
113        } else {
114            let worktrees = workspace.read(cx).visible_worktrees(cx).collect::<Vec<_>>();
115            let candidate_sets = worktrees
116                .into_iter()
117                .map(|worktree| {
118                    let worktree = worktree.read(cx);
119
120                    PathMatchCandidateSet {
121                        snapshot: worktree.snapshot(),
122                        include_ignored: worktree
123                            .root_entry()
124                            .map_or(false, |entry| entry.is_ignored),
125                        include_root_name: true,
126                        candidates: project::Candidates::Files,
127                    }
128                })
129                .collect::<Vec<_>>();
130
131            let executor = cx.background_executor().clone();
132            cx.foreground_executor().spawn(async move {
133                fuzzy::match_path_sets(
134                    candidate_sets.as_slice(),
135                    query.as_str(),
136                    None,
137                    false,
138                    100,
139                    &cancellation_flag,
140                    executor,
141                )
142                .await
143            })
144        }
145    }
146}
147
148impl PickerDelegate for FileContextPickerDelegate {
149    type ListItem = ListItem;
150
151    fn match_count(&self) -> usize {
152        self.matches.len()
153    }
154
155    fn selected_index(&self) -> usize {
156        self.selected_index
157    }
158
159    fn set_selected_index(&mut self, ix: usize, _cx: &mut ViewContext<Picker<Self>>) {
160        self.selected_index = ix;
161    }
162
163    fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc<str> {
164        "Search files…".into()
165    }
166
167    fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()> {
168        let Some(workspace) = self.workspace.upgrade() else {
169            return Task::ready(());
170        };
171
172        let search_task = self.search(query, Arc::<AtomicBool>::default(), &workspace, cx);
173
174        cx.spawn(|this, mut cx| async move {
175            // TODO: This should be probably be run in the background.
176            let paths = search_task.await;
177
178            this.update(&mut cx, |this, _cx| {
179                this.delegate.matches = paths;
180            })
181            .log_err();
182        })
183    }
184
185    fn confirm(&mut self, _secondary: bool, cx: &mut ViewContext<Picker<Self>>) {
186        let mat = &self.matches[self.selected_index];
187
188        let workspace = self.workspace.clone();
189        let Some(project) = workspace
190            .upgrade()
191            .map(|workspace| workspace.read(cx).project().clone())
192        else {
193            return;
194        };
195        let path = mat.path.clone();
196        let worktree_id = WorktreeId::from_usize(mat.worktree_id);
197        cx.spawn(|this, mut cx| async move {
198            let Some(open_buffer_task) = project
199                .update(&mut cx, |project, cx| {
200                    project.open_buffer((worktree_id, path.clone()), cx)
201                })
202                .ok()
203            else {
204                return anyhow::Ok(());
205            };
206
207            let buffer = open_buffer_task.await?;
208
209            this.update(&mut cx, |this, cx| {
210                this.delegate.context_store.update(cx, |context_store, cx| {
211                    let mut text = String::new();
212                    text.push_str(&codeblock_fence_for_path(Some(&path), None));
213                    text.push_str(&buffer.read(cx).text());
214                    if !text.ends_with('\n') {
215                        text.push('\n');
216                    }
217
218                    text.push_str("```\n");
219
220                    context_store.insert_context(
221                        ContextKind::File,
222                        path.to_string_lossy().to_string(),
223                        text,
224                    );
225                })
226            })??;
227
228            anyhow::Ok(())
229        })
230        .detach_and_log_err(cx);
231    }
232
233    fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
234        self.context_picker
235            .update(cx, |this, cx| {
236                this.reset_mode();
237                cx.emit(DismissEvent);
238            })
239            .ok();
240    }
241
242    fn render_match(
243        &self,
244        ix: usize,
245        selected: bool,
246        _cx: &mut ViewContext<Picker<Self>>,
247    ) -> Option<Self::ListItem> {
248        let path_match = &self.matches[ix];
249
250        let (file_name, directory) = if path_match.path.as_ref() == Path::new("") {
251            (SharedString::from(path_match.path_prefix.clone()), None)
252        } else {
253            let file_name = path_match
254                .path
255                .file_name()
256                .unwrap_or_default()
257                .to_string_lossy()
258                .to_string()
259                .into();
260
261            let mut directory = format!("{}/", path_match.path_prefix);
262            if let Some(parent) = path_match
263                .path
264                .parent()
265                .filter(|parent| parent != &Path::new(""))
266            {
267                directory.push_str(&parent.to_string_lossy());
268                directory.push('/');
269            }
270
271            (file_name, Some(directory))
272        };
273
274        Some(
275            ListItem::new(ix).inset(true).toggle_state(selected).child(
276                h_flex()
277                    .gap_2()
278                    .child(Label::new(file_name))
279                    .children(directory.map(|directory| {
280                        Label::new(directory)
281                            .size(LabelSize::Small)
282                            .color(Color::Muted)
283                    })),
284            ),
285        )
286    }
287}
288
289fn codeblock_fence_for_path(path: Option<&Path>, row_range: Option<RangeInclusive<u32>>) -> String {
290    let mut text = String::new();
291    write!(text, "```").unwrap();
292
293    if let Some(path) = path {
294        if let Some(extension) = path.extension().and_then(|ext| ext.to_str()) {
295            write!(text, "{} ", extension).unwrap();
296        }
297
298        write!(text, "{}", path.display()).unwrap();
299    } else {
300        write!(text, "untitled").unwrap();
301    }
302
303    if let Some(row_range) = row_range {
304        write!(text, ":{}-{}", row_range.start() + 1, row_range.end() + 1).unwrap();
305    }
306
307    text.push('\n');
308    text
309}