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