1use std::collections::BTreeSet;
2use std::ops::Range;
3use std::path::Path;
4use std::sync::atomic::AtomicBool;
5use std::sync::Arc;
6
7use editor::actions::FoldAt;
8use editor::display_map::{Crease, FoldId};
9use editor::scroll::Autoscroll;
10use editor::{Anchor, Editor, FoldPlaceholder, ToPoint};
11use file_icons::FileIcons;
12use fuzzy::PathMatch;
13use gpui::{
14 AnyElement, AppContext, DismissEvent, Empty, FocusHandle, FocusableView, Stateful, Task, View,
15 WeakModel, WeakView,
16};
17use multi_buffer::{MultiBufferPoint, MultiBufferRow};
18use picker::{Picker, PickerDelegate};
19use project::{PathMatchCandidateSet, ProjectPath, WorktreeId};
20use rope::Point;
21use text::SelectionGoal;
22use ui::{prelude::*, ButtonLike, Disclosure, ElevationIndex, ListItem, Tooltip};
23use util::ResultExt as _;
24use workspace::{notifications::NotifyResultExt, Workspace};
25
26use crate::context_picker::{ConfirmBehavior, ContextPicker};
27use crate::context_store::{ContextStore, FileInclusion};
28
29pub struct FileContextPicker {
30 picker: View<Picker<FileContextPickerDelegate>>,
31}
32
33impl FileContextPicker {
34 pub fn new(
35 context_picker: WeakView<ContextPicker>,
36 workspace: WeakView<Workspace>,
37 editor: WeakView<Editor>,
38 context_store: WeakModel<ContextStore>,
39 confirm_behavior: ConfirmBehavior,
40 cx: &mut ViewContext<Self>,
41 ) -> Self {
42 let delegate = FileContextPickerDelegate::new(
43 context_picker,
44 workspace,
45 editor,
46 context_store,
47 confirm_behavior,
48 );
49 let picker = cx.new_view(|cx| Picker::uniform_list(delegate, cx));
50
51 Self { picker }
52 }
53}
54
55impl FocusableView for FileContextPicker {
56 fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
57 self.picker.focus_handle(cx)
58 }
59}
60
61impl Render for FileContextPicker {
62 fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
63 self.picker.clone()
64 }
65}
66
67pub struct FileContextPickerDelegate {
68 context_picker: WeakView<ContextPicker>,
69 workspace: WeakView<Workspace>,
70 editor: WeakView<Editor>,
71 context_store: WeakModel<ContextStore>,
72 confirm_behavior: ConfirmBehavior,
73 matches: Vec<PathMatch>,
74 selected_index: usize,
75}
76
77impl FileContextPickerDelegate {
78 pub fn new(
79 context_picker: WeakView<ContextPicker>,
80 workspace: WeakView<Workspace>,
81 editor: WeakView<Editor>,
82 context_store: WeakModel<ContextStore>,
83 confirm_behavior: ConfirmBehavior,
84 ) -> Self {
85 Self {
86 context_picker,
87 workspace,
88 editor,
89 context_store,
90 confirm_behavior,
91 matches: Vec::new(),
92 selected_index: 0,
93 }
94 }
95
96 fn search(
97 &mut self,
98 query: String,
99 cancellation_flag: Arc<AtomicBool>,
100 workspace: &View<Workspace>,
101 cx: &mut ViewContext<Picker<Self>>,
102 ) -> Task<Vec<PathMatch>> {
103 if query.is_empty() {
104 let workspace = workspace.read(cx);
105 let project = workspace.project().read(cx);
106 let recent_matches = workspace
107 .recent_navigation_history(Some(10), cx)
108 .into_iter()
109 .filter_map(|(project_path, _)| {
110 let worktree = project.worktree_for_id(project_path.worktree_id, cx)?;
111 Some(PathMatch {
112 score: 0.,
113 positions: Vec::new(),
114 worktree_id: project_path.worktree_id.to_usize(),
115 path: project_path.path,
116 path_prefix: worktree.read(cx).root_name().into(),
117 distance_to_relative_ancestor: 0,
118 is_dir: false,
119 })
120 });
121
122 let file_matches = project.worktrees(cx).flat_map(|worktree| {
123 let worktree = worktree.read(cx);
124 let path_prefix: Arc<str> = worktree.root_name().into();
125 worktree.files(true, 0).map(move |entry| PathMatch {
126 score: 0.,
127 positions: Vec::new(),
128 worktree_id: worktree.id().to_usize(),
129 path: entry.path.clone(),
130 path_prefix: path_prefix.clone(),
131 distance_to_relative_ancestor: 0,
132 is_dir: false,
133 })
134 });
135
136 Task::ready(recent_matches.chain(file_matches).collect())
137 } else {
138 let worktrees = workspace.read(cx).visible_worktrees(cx).collect::<Vec<_>>();
139 let candidate_sets = worktrees
140 .into_iter()
141 .map(|worktree| {
142 let worktree = worktree.read(cx);
143
144 PathMatchCandidateSet {
145 snapshot: worktree.snapshot(),
146 include_ignored: worktree
147 .root_entry()
148 .map_or(false, |entry| entry.is_ignored),
149 include_root_name: true,
150 candidates: project::Candidates::Files,
151 }
152 })
153 .collect::<Vec<_>>();
154
155 let executor = cx.background_executor().clone();
156 cx.foreground_executor().spawn(async move {
157 fuzzy::match_path_sets(
158 candidate_sets.as_slice(),
159 query.as_str(),
160 None,
161 false,
162 100,
163 &cancellation_flag,
164 executor,
165 )
166 .await
167 })
168 }
169 }
170}
171
172impl PickerDelegate for FileContextPickerDelegate {
173 type ListItem = ListItem;
174
175 fn match_count(&self) -> usize {
176 self.matches.len()
177 }
178
179 fn selected_index(&self) -> usize {
180 self.selected_index
181 }
182
183 fn set_selected_index(&mut self, ix: usize, _cx: &mut ViewContext<Picker<Self>>) {
184 self.selected_index = ix;
185 }
186
187 fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc<str> {
188 "Search files…".into()
189 }
190
191 fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()> {
192 let Some(workspace) = self.workspace.upgrade() else {
193 return Task::ready(());
194 };
195
196 let search_task = self.search(query, Arc::<AtomicBool>::default(), &workspace, cx);
197
198 cx.spawn(|this, mut cx| async move {
199 // TODO: This should be probably be run in the background.
200 let paths = search_task.await;
201
202 this.update(&mut cx, |this, _cx| {
203 this.delegate.matches = paths;
204 })
205 .log_err();
206 })
207 }
208
209 fn confirm(&mut self, _secondary: bool, cx: &mut ViewContext<Picker<Self>>) {
210 let Some(mat) = self.matches.get(self.selected_index) else {
211 return;
212 };
213
214 let Some(file_name) = mat
215 .path
216 .file_name()
217 .map(|os_str| os_str.to_string_lossy().into_owned())
218 else {
219 return;
220 };
221
222 let full_path = mat.path.display().to_string();
223
224 let project_path = ProjectPath {
225 worktree_id: WorktreeId::from_usize(mat.worktree_id),
226 path: mat.path.clone(),
227 };
228
229 let Some(editor) = self.editor.upgrade() else {
230 return;
231 };
232
233 editor.update(cx, |editor, cx| {
234 editor.transact(cx, |editor, cx| {
235 // Move empty selections left by 1 column to select the `@`s, so they get overwritten when we insert.
236 {
237 let mut selections = editor.selections.all::<MultiBufferPoint>(cx);
238
239 for selection in selections.iter_mut() {
240 if selection.is_empty() {
241 let old_head = selection.head();
242 let new_head = MultiBufferPoint::new(
243 old_head.row,
244 old_head.column.saturating_sub(1),
245 );
246 selection.set_head(new_head, SelectionGoal::None);
247 }
248 }
249
250 editor.change_selections(Some(Autoscroll::fit()), cx, |s| s.select(selections));
251 }
252
253 let start_anchors = {
254 let snapshot = editor.buffer().read(cx).snapshot(cx);
255 editor
256 .selections
257 .all::<Point>(cx)
258 .into_iter()
259 .map(|selection| snapshot.anchor_before(selection.start))
260 .collect::<Vec<_>>()
261 };
262
263 editor.insert(&full_path, cx);
264
265 let end_anchors = {
266 let snapshot = editor.buffer().read(cx).snapshot(cx);
267 editor
268 .selections
269 .all::<Point>(cx)
270 .into_iter()
271 .map(|selection| snapshot.anchor_after(selection.end))
272 .collect::<Vec<_>>()
273 };
274
275 editor.insert("\n", cx); // Needed to end the fold
276
277 let placeholder = FoldPlaceholder {
278 render: render_fold_icon_button(IconName::File, file_name.into()),
279 ..Default::default()
280 };
281
282 let render_trailer = move |_row, _unfold, _cx: &mut WindowContext| Empty.into_any();
283
284 let buffer = editor.buffer().read(cx).snapshot(cx);
285 let mut rows_to_fold = BTreeSet::new();
286 let crease_iter = start_anchors
287 .into_iter()
288 .zip(end_anchors)
289 .map(|(start, end)| {
290 rows_to_fold.insert(MultiBufferRow(start.to_point(&buffer).row));
291
292 Crease::inline(
293 start..end,
294 placeholder.clone(),
295 fold_toggle("tool-use"),
296 render_trailer,
297 )
298 });
299
300 editor.insert_creases(crease_iter, cx);
301
302 for buffer_row in rows_to_fold {
303 editor.fold_at(&FoldAt { buffer_row }, cx);
304 }
305 });
306 });
307
308 let Some(task) = self
309 .context_store
310 .update(cx, |context_store, cx| {
311 context_store.add_file_from_path(project_path, cx)
312 })
313 .ok()
314 else {
315 return;
316 };
317
318 let confirm_behavior = self.confirm_behavior;
319 cx.spawn(|this, mut cx| async move {
320 match task.await.notify_async_err(&mut cx) {
321 None => anyhow::Ok(()),
322 Some(()) => this.update(&mut cx, |this, cx| match confirm_behavior {
323 ConfirmBehavior::KeepOpen => {}
324 ConfirmBehavior::Close => this.delegate.dismissed(cx),
325 }),
326 }
327 })
328 .detach_and_log_err(cx);
329 }
330
331 fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
332 self.context_picker
333 .update(cx, |_, cx| {
334 cx.emit(DismissEvent);
335 })
336 .ok();
337 }
338
339 fn render_match(
340 &self,
341 ix: usize,
342 selected: bool,
343 cx: &mut ViewContext<Picker<Self>>,
344 ) -> Option<Self::ListItem> {
345 let path_match = &self.matches[ix];
346
347 Some(
348 ListItem::new(ix)
349 .inset(true)
350 .toggle_state(selected)
351 .child(render_file_context_entry(
352 ElementId::NamedInteger("file-ctx-picker".into(), ix),
353 &path_match.path,
354 &path_match.path_prefix,
355 self.context_store.clone(),
356 cx,
357 )),
358 )
359 }
360}
361
362pub fn render_file_context_entry(
363 id: ElementId,
364 path: &Path,
365 path_prefix: &Arc<str>,
366 context_store: WeakModel<ContextStore>,
367 cx: &WindowContext,
368) -> Stateful<Div> {
369 let (file_name, directory) = if path == Path::new("") {
370 (SharedString::from(path_prefix.clone()), None)
371 } else {
372 let file_name = path
373 .file_name()
374 .unwrap_or_default()
375 .to_string_lossy()
376 .to_string()
377 .into();
378
379 let mut directory = format!("{}/", path_prefix);
380
381 if let Some(parent) = path.parent().filter(|parent| parent != &Path::new("")) {
382 directory.push_str(&parent.to_string_lossy());
383 directory.push('/');
384 }
385
386 (file_name, Some(directory))
387 };
388
389 let added = context_store
390 .upgrade()
391 .and_then(|context_store| context_store.read(cx).will_include_file_path(path, cx));
392
393 let file_icon = FileIcons::get_icon(&path, cx)
394 .map(Icon::from_path)
395 .unwrap_or_else(|| Icon::new(IconName::File));
396
397 h_flex()
398 .id(id)
399 .gap_1()
400 .w_full()
401 .child(file_icon.size(IconSize::Small))
402 .child(
403 h_flex()
404 .gap_2()
405 .child(Label::new(file_name))
406 .children(directory.map(|directory| {
407 Label::new(directory)
408 .size(LabelSize::Small)
409 .color(Color::Muted)
410 })),
411 )
412 .child(div().w_full())
413 .when_some(added, |el, added| match added {
414 FileInclusion::Direct(_) => el.child(
415 h_flex()
416 .gap_1()
417 .child(
418 Icon::new(IconName::Check)
419 .size(IconSize::Small)
420 .color(Color::Success),
421 )
422 .child(Label::new("Added").size(LabelSize::Small)),
423 ),
424 FileInclusion::InDirectory(dir_name) => {
425 let dir_name = dir_name.to_string_lossy().into_owned();
426
427 el.child(
428 h_flex()
429 .gap_1()
430 .child(
431 Icon::new(IconName::Check)
432 .size(IconSize::Small)
433 .color(Color::Success),
434 )
435 .child(Label::new("Included").size(LabelSize::Small)),
436 )
437 .tooltip(move |cx| Tooltip::text(format!("in {dir_name}"), cx))
438 }
439 })
440}
441
442fn render_fold_icon_button(
443 icon: IconName,
444 label: SharedString,
445) -> Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &mut WindowContext) -> AnyElement> {
446 Arc::new(move |fold_id, _fold_range, _cx| {
447 ButtonLike::new(fold_id)
448 .style(ButtonStyle::Filled)
449 .layer(ElevationIndex::ElevatedSurface)
450 .child(Icon::new(icon))
451 .child(Label::new(label.clone()).single_line())
452 .into_any_element()
453 })
454}
455
456fn fold_toggle(
457 name: &'static str,
458) -> impl Fn(
459 MultiBufferRow,
460 bool,
461 Arc<dyn Fn(bool, &mut WindowContext) + Send + Sync>,
462 &mut WindowContext,
463) -> AnyElement {
464 move |row, is_folded, fold, _cx| {
465 Disclosure::new((name, row.0 as u64), !is_folded)
466 .toggle_state(is_folded)
467 .on_click(move |_e, cx| fold(!is_folded, cx))
468 .into_any_element()
469 }
470}