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