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