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