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