1use std::sync::Arc;
2use std::sync::atomic::AtomicBool;
3
4use file_icons::FileIcons;
5use fuzzy::PathMatch;
6use gpui::{
7 App, AppContext, DismissEvent, Entity, FocusHandle, Focusable, Stateful, Task, WeakEntity,
8};
9use picker::{Picker, PickerDelegate};
10use project::{PathMatchCandidateSet, ProjectPath, WorktreeId};
11use ui::{ListItem, Tooltip, prelude::*};
12use util::{ResultExt as _, paths::PathStyle, rel_path::RelPath};
13use workspace::Workspace;
14
15use crate::context_picker::ContextPicker;
16use agent::context_store::{ContextStore, FileInclusion};
17
18pub struct FileContextPicker {
19 picker: Entity<Picker<FileContextPickerDelegate>>,
20}
21
22impl FileContextPicker {
23 pub fn new(
24 context_picker: WeakEntity<ContextPicker>,
25 workspace: WeakEntity<Workspace>,
26 context_store: WeakEntity<ContextStore>,
27 window: &mut Window,
28 cx: &mut Context<Self>,
29 ) -> Self {
30 let delegate = FileContextPickerDelegate::new(context_picker, workspace, context_store);
31 let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
32
33 Self { picker }
34 }
35}
36
37impl Focusable for FileContextPicker {
38 fn focus_handle(&self, cx: &App) -> FocusHandle {
39 self.picker.focus_handle(cx)
40 }
41}
42
43impl Render for FileContextPicker {
44 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
45 self.picker.clone()
46 }
47}
48
49pub struct FileContextPickerDelegate {
50 context_picker: WeakEntity<ContextPicker>,
51 workspace: WeakEntity<Workspace>,
52 context_store: WeakEntity<ContextStore>,
53 matches: Vec<FileMatch>,
54 selected_index: usize,
55}
56
57impl FileContextPickerDelegate {
58 pub fn new(
59 context_picker: WeakEntity<ContextPicker>,
60 workspace: WeakEntity<Workspace>,
61 context_store: WeakEntity<ContextStore>,
62 ) -> Self {
63 Self {
64 context_picker,
65 workspace,
66 context_store,
67 matches: Vec::new(),
68 selected_index: 0,
69 }
70 }
71}
72
73impl PickerDelegate for FileContextPickerDelegate {
74 type ListItem = ListItem;
75
76 fn match_count(&self) -> usize {
77 self.matches.len()
78 }
79
80 fn selected_index(&self) -> usize {
81 self.selected_index
82 }
83
84 fn set_selected_index(
85 &mut self,
86 ix: usize,
87 _window: &mut Window,
88 _cx: &mut Context<Picker<Self>>,
89 ) {
90 self.selected_index = ix;
91 }
92
93 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
94 "Search files & directories…".into()
95 }
96
97 fn update_matches(
98 &mut self,
99 query: String,
100 window: &mut Window,
101 cx: &mut Context<Picker<Self>>,
102 ) -> Task<()> {
103 let Some(workspace) = self.workspace.upgrade() else {
104 return Task::ready(());
105 };
106
107 let search_task = search_files(query, Arc::<AtomicBool>::default(), &workspace, cx);
108
109 cx.spawn_in(window, async move |this, cx| {
110 // TODO: This should be probably be run in the background.
111 let paths = search_task.await;
112
113 this.update(cx, |this, _cx| {
114 this.delegate.matches = paths;
115 })
116 .log_err();
117 })
118 }
119
120 fn confirm(&mut self, _secondary: bool, _window: &mut Window, cx: &mut Context<Picker<Self>>) {
121 let Some(FileMatch { mat, .. }) = self.matches.get(self.selected_index) else {
122 return;
123 };
124
125 let project_path = ProjectPath {
126 worktree_id: WorktreeId::from_usize(mat.worktree_id),
127 path: mat.path.clone(),
128 };
129
130 let is_directory = mat.is_dir;
131
132 self.context_store
133 .update(cx, |context_store, cx| {
134 if is_directory {
135 context_store
136 .add_directory(&project_path, true, cx)
137 .log_err();
138 } else {
139 context_store
140 .add_file_from_path(project_path.clone(), true, cx)
141 .detach_and_log_err(cx);
142 }
143 })
144 .ok();
145 }
146
147 fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
148 self.context_picker
149 .update(cx, |_, cx| {
150 cx.emit(DismissEvent);
151 })
152 .ok();
153 }
154
155 fn render_match(
156 &self,
157 ix: usize,
158 selected: bool,
159 _window: &mut Window,
160 cx: &mut Context<Picker<Self>>,
161 ) -> Option<Self::ListItem> {
162 let FileMatch { mat, .. } = &self.matches.get(ix)?;
163 let workspace = self.workspace.upgrade()?;
164 let path_style = workspace.read(cx).path_style(cx);
165
166 Some(
167 ListItem::new(ix)
168 .inset(true)
169 .toggle_state(selected)
170 .child(render_file_context_entry(
171 ElementId::named_usize("file-ctx-picker", ix),
172 WorktreeId::from_usize(mat.worktree_id),
173 &mat.path,
174 &mat.path_prefix,
175 mat.is_dir,
176 path_style,
177 self.context_store.clone(),
178 cx,
179 )),
180 )
181 }
182}
183
184pub struct FileMatch {
185 pub mat: PathMatch,
186 pub is_recent: bool,
187}
188
189pub(crate) fn search_files(
190 query: String,
191 cancellation_flag: Arc<AtomicBool>,
192 workspace: &Entity<Workspace>,
193 cx: &App,
194) -> Task<Vec<FileMatch>> {
195 if query.is_empty() {
196 let workspace = workspace.read(cx);
197 let project = workspace.project().read(cx);
198 let recent_matches = workspace
199 .recent_navigation_history(Some(10), cx)
200 .into_iter()
201 .filter_map(|(project_path, _)| {
202 let worktree = project.worktree_for_id(project_path.worktree_id, cx)?;
203 Some(FileMatch {
204 mat: PathMatch {
205 score: 0.,
206 positions: Vec::new(),
207 worktree_id: project_path.worktree_id.to_usize(),
208 path: project_path.path,
209 path_prefix: worktree.read(cx).root_name().into(),
210 distance_to_relative_ancestor: 0,
211 is_dir: false,
212 },
213 is_recent: true,
214 })
215 });
216
217 let file_matches = project.worktrees(cx).flat_map(|worktree| {
218 let worktree = worktree.read(cx);
219 worktree.entries(false, 0).map(move |entry| FileMatch {
220 mat: PathMatch {
221 score: 0.,
222 positions: Vec::new(),
223 worktree_id: worktree.id().to_usize(),
224 path: entry.path.clone(),
225 path_prefix: worktree.root_name().into(),
226 distance_to_relative_ancestor: 0,
227 is_dir: entry.is_dir(),
228 },
229 is_recent: false,
230 })
231 });
232
233 Task::ready(recent_matches.chain(file_matches).collect())
234 } else {
235 let worktrees = workspace.read(cx).visible_worktrees(cx).collect::<Vec<_>>();
236 let candidate_sets = worktrees
237 .into_iter()
238 .map(|worktree| {
239 let worktree = worktree.read(cx);
240
241 PathMatchCandidateSet {
242 snapshot: worktree.snapshot(),
243 include_ignored: worktree.root_entry().is_some_and(|entry| entry.is_ignored),
244 include_root_name: true,
245 candidates: project::Candidates::Entries,
246 }
247 })
248 .collect::<Vec<_>>();
249
250 let executor = cx.background_executor().clone();
251 cx.foreground_executor().spawn(async move {
252 fuzzy::match_path_sets(
253 candidate_sets.as_slice(),
254 query.as_str(),
255 &None,
256 false,
257 100,
258 &cancellation_flag,
259 executor,
260 )
261 .await
262 .into_iter()
263 .map(|mat| FileMatch {
264 mat,
265 is_recent: false,
266 })
267 .collect::<Vec<_>>()
268 })
269 }
270}
271
272pub fn extract_file_name_and_directory(
273 path: &RelPath,
274 path_prefix: &RelPath,
275 path_style: PathStyle,
276) -> (SharedString, Option<SharedString>) {
277 let full_path = path_prefix.join(path);
278 let file_name = full_path.file_name().unwrap_or_default();
279 let display_path = full_path.display(path_style);
280 let (directory, file_name) = display_path.split_at(display_path.len() - file_name.len());
281 (
282 file_name.to_string().into(),
283 Some(SharedString::new(directory)).filter(|dir| !dir.is_empty()),
284 )
285}
286
287pub fn render_file_context_entry(
288 id: ElementId,
289 worktree_id: WorktreeId,
290 path: &Arc<RelPath>,
291 path_prefix: &Arc<RelPath>,
292 is_directory: bool,
293 path_style: PathStyle,
294 context_store: WeakEntity<ContextStore>,
295 cx: &App,
296) -> Stateful<Div> {
297 let (file_name, directory) = extract_file_name_and_directory(path, path_prefix, path_style);
298
299 let added = context_store.upgrade().and_then(|context_store| {
300 let project_path = ProjectPath {
301 worktree_id,
302 path: path.clone(),
303 };
304 if is_directory {
305 context_store
306 .read(cx)
307 .path_included_in_directory(&project_path, cx)
308 } else {
309 context_store.read(cx).file_path_included(&project_path, cx)
310 }
311 });
312
313 let file_icon = if is_directory {
314 FileIcons::get_folder_icon(false, path.as_std_path(), cx)
315 } else {
316 FileIcons::get_icon(path.as_std_path(), cx)
317 }
318 .map(Icon::from_path)
319 .unwrap_or_else(|| Icon::new(IconName::File));
320
321 h_flex()
322 .id(id)
323 .gap_1p5()
324 .w_full()
325 .child(file_icon.size(IconSize::Small).color(Color::Muted))
326 .child(
327 h_flex()
328 .gap_1()
329 .child(Label::new(file_name))
330 .children(directory.map(|directory| {
331 Label::new(directory)
332 .size(LabelSize::Small)
333 .color(Color::Muted)
334 })),
335 )
336 .when_some(added, |el, added| match added {
337 FileInclusion::Direct => el.child(
338 h_flex()
339 .w_full()
340 .justify_end()
341 .gap_0p5()
342 .child(
343 Icon::new(IconName::Check)
344 .size(IconSize::Small)
345 .color(Color::Success),
346 )
347 .child(Label::new("Added").size(LabelSize::Small)),
348 ),
349 FileInclusion::InDirectory { full_path } => {
350 let directory_full_path = full_path.to_string_lossy().into_owned();
351
352 el.child(
353 h_flex()
354 .w_full()
355 .justify_end()
356 .gap_0p5()
357 .child(
358 Icon::new(IconName::Check)
359 .size(IconSize::Small)
360 .color(Color::Success),
361 )
362 .child(Label::new("Included").size(LabelSize::Small)),
363 )
364 .tooltip(Tooltip::text(format!("in {directory_full_path}")))
365 }
366 })
367}