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