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