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, 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::message_editor::MessageEditor;
 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        message_editor: WeakView<MessageEditor>,
 28        cx: &mut ViewContext<Self>,
 29    ) -> Self {
 30        let delegate = FileContextPickerDelegate::new(context_picker, workspace, message_editor);
 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    message_editor: WeakView<MessageEditor>,
 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        message_editor: WeakView<MessageEditor>,
 62    ) -> Self {
 63        Self {
 64            context_picker,
 65            workspace,
 66            message_editor,
 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
218                    .message_editor
219                    .update(cx, |message_editor, cx| {
220                        let mut text = String::new();
221                        text.push_str(&codeblock_fence_for_path(Some(&path), None));
222                        text.push_str(&buffer.read(cx).text());
223                        if !text.ends_with('\n') {
224                            text.push('\n');
225                        }
226
227                        text.push_str("```\n");
228
229                        message_editor.insert_context(
230                            ContextKind::File,
231                            path.to_string_lossy().to_string(),
232                            text,
233                        );
234                    })
235            })??;
236
237            anyhow::Ok(())
238        })
239        .detach_and_log_err(cx);
240    }
241
242    fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
243        self.context_picker
244            .update(cx, |this, cx| {
245                this.reset_mode();
246                cx.emit(DismissEvent);
247            })
248            .ok();
249    }
250
251    fn render_match(
252        &self,
253        ix: usize,
254        selected: bool,
255        _cx: &mut ViewContext<Picker<Self>>,
256    ) -> Option<Self::ListItem> {
257        let path_match = &self.matches[ix];
258        let file_name = path_match
259            .path
260            .file_name()
261            .unwrap_or_default()
262            .to_string_lossy()
263            .to_string();
264        let directory = path_match
265            .path
266            .parent()
267            .map(|directory| format!("{}/", directory.to_string_lossy()));
268
269        Some(
270            ListItem::new(ix).inset(true).toggle_state(selected).child(
271                h_flex()
272                    .gap_2()
273                    .child(Label::new(file_name))
274                    .children(directory.map(|directory| {
275                        Label::new(directory)
276                            .size(LabelSize::Small)
277                            .color(Color::Muted)
278                    })),
279            ),
280        )
281    }
282}
283
284fn codeblock_fence_for_path(path: Option<&Path>, row_range: Option<RangeInclusive<u32>>) -> String {
285    let mut text = String::new();
286    write!(text, "```").unwrap();
287
288    if let Some(path) = path {
289        if let Some(extension) = path.extension().and_then(|ext| ext.to_str()) {
290            write!(text, "{} ", extension).unwrap();
291        }
292
293        write!(text, "{}", path.display()).unwrap();
294    } else {
295        write!(text, "untitled").unwrap();
296    }
297
298    if let Some(row_range) = row_range {
299        write!(text, ":{}-{}", row_range.start() + 1, row_range.end() + 1).unwrap();
300    }
301
302    text.push('\n');
303    text
304}