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 recent_matches = workspace
201 .recent_navigation_history(Some(10), cx)
202 .into_iter()
203 .filter_map(|(project_path, _)| {
204 let worktree = project.worktree_for_id(project_path.worktree_id, cx)?;
205 Some(FileMatch {
206 mat: PathMatch {
207 score: 0.,
208 positions: Vec::new(),
209 worktree_id: project_path.worktree_id.to_usize(),
210 path: project_path.path,
211 path_prefix: worktree.read(cx).root_name().into(),
212 distance_to_relative_ancestor: 0,
213 is_dir: false,
214 },
215 is_recent: true,
216 })
217 });
218
219 let file_matches = project.worktrees(cx).flat_map(|worktree| {
220 let worktree = worktree.read(cx);
221 worktree.entries(false, 0).map(move |entry| FileMatch {
222 mat: PathMatch {
223 score: 0.,
224 positions: Vec::new(),
225 worktree_id: worktree.id().to_usize(),
226 path: entry.path.clone(),
227 path_prefix: worktree.root_name().into(),
228 distance_to_relative_ancestor: 0,
229 is_dir: entry.is_dir(),
230 },
231 is_recent: false,
232 })
233 });
234
235 Task::ready(recent_matches.chain(file_matches).collect())
236 } else {
237 let worktrees = workspace.read(cx).visible_worktrees(cx).collect::<Vec<_>>();
238 let candidate_sets = worktrees
239 .into_iter()
240 .map(|worktree| {
241 let worktree = worktree.read(cx);
242
243 PathMatchCandidateSet {
244 snapshot: worktree.snapshot(),
245 include_ignored: worktree.root_entry().is_some_and(|entry| entry.is_ignored),
246 include_root_name: true,
247 candidates: project::Candidates::Entries,
248 }
249 })
250 .collect::<Vec<_>>();
251
252 let executor = cx.background_executor().clone();
253 cx.foreground_executor().spawn(async move {
254 fuzzy::match_path_sets(
255 candidate_sets.as_slice(),
256 query.as_str(),
257 &None,
258 false,
259 100,
260 &cancellation_flag,
261 executor,
262 )
263 .await
264 .into_iter()
265 .map(|mat| FileMatch {
266 mat,
267 is_recent: false,
268 })
269 .collect::<Vec<_>>()
270 })
271 }
272}
273
274pub fn extract_file_name_and_directory(
275 path: &RelPath,
276 path_prefix: &RelPath,
277 path_style: PathStyle,
278) -> (SharedString, Option<SharedString>) {
279 let full_path = path_prefix.join(path);
280 let file_name = full_path.file_name().unwrap_or_default();
281 let display_path = full_path.display(path_style);
282 let (directory, file_name) = display_path.split_at(display_path.len() - file_name.len());
283 (
284 file_name.to_string().into(),
285 Some(SharedString::new(directory)).filter(|dir| !dir.is_empty()),
286 )
287}
288
289pub fn render_file_context_entry(
290 id: ElementId,
291 worktree_id: WorktreeId,
292 path: &Arc<RelPath>,
293 path_prefix: &Arc<RelPath>,
294 is_directory: bool,
295 path_style: PathStyle,
296 context_store: WeakEntity<ContextStore>,
297 cx: &App,
298) -> Stateful<Div> {
299 let (file_name, directory) = extract_file_name_and_directory(path, path_prefix, path_style);
300
301 let added = context_store.upgrade().and_then(|context_store| {
302 let project_path = ProjectPath {
303 worktree_id,
304 path: path.clone(),
305 };
306 if is_directory {
307 context_store
308 .read(cx)
309 .path_included_in_directory(&project_path, cx)
310 } else {
311 context_store.read(cx).file_path_included(&project_path, cx)
312 }
313 });
314
315 let file_icon = if is_directory {
316 FileIcons::get_folder_icon(false, path.as_std_path(), cx)
317 } else {
318 FileIcons::get_icon(path.as_std_path(), cx)
319 }
320 .map(Icon::from_path)
321 .unwrap_or_else(|| Icon::new(IconName::File));
322
323 h_flex()
324 .id(id)
325 .gap_1p5()
326 .w_full()
327 .child(file_icon.size(IconSize::Small).color(Color::Muted))
328 .child(
329 h_flex()
330 .gap_1()
331 .child(Label::new(file_name))
332 .children(directory.map(|directory| {
333 Label::new(directory)
334 .size(LabelSize::Small)
335 .color(Color::Muted)
336 })),
337 )
338 .when_some(added, |el, added| match added {
339 FileInclusion::Direct => el.child(
340 h_flex()
341 .w_full()
342 .justify_end()
343 .gap_0p5()
344 .child(
345 Icon::new(IconName::Check)
346 .size(IconSize::Small)
347 .color(Color::Success),
348 )
349 .child(Label::new("Added").size(LabelSize::Small)),
350 ),
351 FileInclusion::InDirectory { full_path } => {
352 let directory_full_path = full_path.to_string_lossy().into_owned();
353
354 el.child(
355 h_flex()
356 .w_full()
357 .justify_end()
358 .gap_0p5()
359 .child(
360 Icon::new(IconName::Check)
361 .size(IconSize::Small)
362 .color(Color::Success),
363 )
364 .child(Label::new("Included").size(LabelSize::Small)),
365 )
366 .tooltip(Tooltip::text(format!("in {directory_full_path}")))
367 }
368 })
369}