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, App, DismissEvent, Empty, Entity, FocusHandle, Focusable, Stateful, Task,
15 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, 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: 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) = self.editor.upgrade() else {
242 return;
243 };
244
245 editor.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(file_icon, file_name.into()),
296 ..Default::default()
297 };
298
299 let render_trailer =
300 move |_row, _unfold, _window: &mut Window, _cx: &mut App| Empty.into_any();
301
302 let buffer = editor.buffer().read(cx).snapshot(cx);
303 let mut rows_to_fold = BTreeSet::new();
304 let crease_iter = start_anchors
305 .into_iter()
306 .zip(end_anchors)
307 .map(|(start, end)| {
308 rows_to_fold.insert(MultiBufferRow(start.to_point(&buffer).row));
309
310 Crease::inline(
311 start..end,
312 placeholder.clone(),
313 fold_toggle("tool-use"),
314 render_trailer,
315 )
316 });
317
318 editor.insert_creases(crease_iter, cx);
319
320 for buffer_row in rows_to_fold {
321 editor.fold_at(&FoldAt { buffer_row }, window, cx);
322 }
323 });
324 });
325
326 let Some(task) = self
327 .context_store
328 .update(cx, |context_store, cx| {
329 context_store.add_file_from_path(project_path, cx)
330 })
331 .ok()
332 else {
333 return;
334 };
335
336 let confirm_behavior = self.confirm_behavior;
337 cx.spawn_in(window, |this, mut cx| async move {
338 match task.await.notify_async_err(&mut cx) {
339 None => anyhow::Ok(()),
340 Some(()) => this.update_in(&mut cx, |this, window, cx| match confirm_behavior {
341 ConfirmBehavior::KeepOpen => {}
342 ConfirmBehavior::Close => this.delegate.dismissed(window, cx),
343 }),
344 }
345 })
346 .detach_and_log_err(cx);
347 }
348
349 fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
350 self.context_picker
351 .update(cx, |_, cx| {
352 cx.emit(DismissEvent);
353 })
354 .ok();
355 }
356
357 fn render_match(
358 &self,
359 ix: usize,
360 selected: bool,
361 _window: &mut Window,
362 cx: &mut Context<Picker<Self>>,
363 ) -> Option<Self::ListItem> {
364 let path_match = &self.matches[ix];
365
366 Some(
367 ListItem::new(ix)
368 .inset(true)
369 .toggle_state(selected)
370 .child(render_file_context_entry(
371 ElementId::NamedInteger("file-ctx-picker".into(), ix),
372 &path_match.path,
373 &path_match.path_prefix,
374 self.context_store.clone(),
375 cx,
376 )),
377 )
378 }
379}
380
381pub fn render_file_context_entry(
382 id: ElementId,
383 path: &Path,
384 path_prefix: &Arc<str>,
385 context_store: WeakEntity<ContextStore>,
386 cx: &App,
387) -> Stateful<Div> {
388 let (file_name, directory) = if path == Path::new("") {
389 (SharedString::from(path_prefix.clone()), None)
390 } else {
391 let file_name = path
392 .file_name()
393 .unwrap_or_default()
394 .to_string_lossy()
395 .to_string()
396 .into();
397
398 let mut directory = format!("{}/", path_prefix);
399
400 if let Some(parent) = path.parent().filter(|parent| parent != &Path::new("")) {
401 directory.push_str(&parent.to_string_lossy());
402 directory.push('/');
403 }
404
405 (file_name, Some(directory))
406 };
407
408 let added = context_store
409 .upgrade()
410 .and_then(|context_store| context_store.read(cx).will_include_file_path(path, cx));
411
412 let file_icon = FileIcons::get_icon(&path, cx)
413 .map(Icon::from_path)
414 .unwrap_or_else(|| Icon::new(IconName::File));
415
416 h_flex()
417 .id(id)
418 .gap_1p5()
419 .w_full()
420 .child(file_icon.size(IconSize::Small).color(Color::Muted))
421 .child(
422 h_flex()
423 .gap_1()
424 .child(Label::new(file_name))
425 .children(directory.map(|directory| {
426 Label::new(directory)
427 .size(LabelSize::Small)
428 .color(Color::Muted)
429 })),
430 )
431 .when_some(added, |el, added| match added {
432 FileInclusion::Direct(_) => el.child(
433 h_flex()
434 .w_full()
435 .justify_end()
436 .gap_0p5()
437 .child(
438 Icon::new(IconName::Check)
439 .size(IconSize::Small)
440 .color(Color::Success),
441 )
442 .child(Label::new("Added").size(LabelSize::Small)),
443 ),
444 FileInclusion::InDirectory(dir_name) => {
445 let dir_name = dir_name.to_string_lossy().into_owned();
446
447 el.child(
448 h_flex()
449 .w_full()
450 .justify_end()
451 .gap_0p5()
452 .child(
453 Icon::new(IconName::Check)
454 .size(IconSize::Small)
455 .color(Color::Success),
456 )
457 .child(Label::new("Included").size(LabelSize::Small)),
458 )
459 .tooltip(Tooltip::text(format!("in {dir_name}")))
460 }
461 })
462}
463
464fn render_fold_icon_button(
465 icon: SharedString,
466 label: SharedString,
467) -> Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &mut Window, &mut App) -> AnyElement> {
468 Arc::new(move |fold_id, _fold_range, _window, _cx| {
469 ButtonLike::new(fold_id)
470 .style(ButtonStyle::Filled)
471 .layer(ElevationIndex::ElevatedSurface)
472 .child(
473 h_flex()
474 .gap_1()
475 .child(
476 Icon::from_path(icon.clone())
477 .size(IconSize::Small)
478 .color(Color::Muted),
479 )
480 .child(
481 Label::new(label.clone())
482 .size(LabelSize::Small)
483 .single_line(),
484 ),
485 )
486 .into_any_element()
487 })
488}
489
490fn fold_toggle(
491 name: &'static str,
492) -> impl Fn(
493 MultiBufferRow,
494 bool,
495 Arc<dyn Fn(bool, &mut Window, &mut App) + Send + Sync>,
496 &mut Window,
497 &mut App,
498) -> AnyElement {
499 move |row, is_folded, fold, _window, _cx| {
500 Disclosure::new((name, row.0 as u64), !is_folded)
501 .toggle_state(is_folded)
502 .on_click(move |_e, window, cx| fold(!is_folded, window, cx))
503 .into_any_element()
504 }
505}