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::{notifications::NotifyResultExt, 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 confirm_behavior = self.confirm_behavior;
215 cx.spawn(|this, mut cx| async move {
216 match task.await.notify_async_err(&mut cx) {
217 None => anyhow::Ok(()),
218 Some(()) => this.update(&mut cx, |this, cx| match confirm_behavior {
219 ConfirmBehavior::KeepOpen => {}
220 ConfirmBehavior::Close => this.delegate.dismissed(cx),
221 }),
222 }
223 })
224 .detach_and_log_err(cx);
225 }
226
227 fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
228 self.context_picker
229 .update(cx, |_, cx| {
230 cx.emit(DismissEvent);
231 })
232 .ok();
233 }
234
235 fn render_match(
236 &self,
237 ix: usize,
238 selected: bool,
239 cx: &mut ViewContext<Picker<Self>>,
240 ) -> Option<Self::ListItem> {
241 let path_match = &self.matches[ix];
242
243 Some(
244 ListItem::new(ix)
245 .inset(true)
246 .toggle_state(selected)
247 .child(render_file_context_entry(
248 ElementId::NamedInteger("file-ctx-picker".into(), ix),
249 &path_match.path,
250 &path_match.path_prefix,
251 self.context_store.clone(),
252 cx,
253 )),
254 )
255 }
256}
257
258pub fn render_file_context_entry(
259 id: ElementId,
260 path: &Path,
261 path_prefix: &Arc<str>,
262 context_store: WeakModel<ContextStore>,
263 cx: &WindowContext,
264) -> Stateful<Div> {
265 let (file_name, directory) = if path == Path::new("") {
266 (SharedString::from(path_prefix.clone()), None)
267 } else {
268 let file_name = path
269 .file_name()
270 .unwrap_or_default()
271 .to_string_lossy()
272 .to_string()
273 .into();
274
275 let mut directory = format!("{}/", path_prefix);
276
277 if let Some(parent) = path.parent().filter(|parent| parent != &Path::new("")) {
278 directory.push_str(&parent.to_string_lossy());
279 directory.push('/');
280 }
281
282 (file_name, Some(directory))
283 };
284
285 let added = context_store
286 .upgrade()
287 .and_then(|context_store| context_store.read(cx).will_include_file_path(path, cx));
288
289 let file_icon = FileIcons::get_icon(&path, cx)
290 .map(Icon::from_path)
291 .unwrap_or_else(|| Icon::new(IconName::File));
292
293 h_flex()
294 .id(id)
295 .gap_1()
296 .w_full()
297 .child(file_icon.size(IconSize::Small))
298 .child(
299 h_flex()
300 .gap_2()
301 .child(Label::new(file_name))
302 .children(directory.map(|directory| {
303 Label::new(directory)
304 .size(LabelSize::Small)
305 .color(Color::Muted)
306 })),
307 )
308 .child(div().w_full())
309 .when_some(added, |el, added| match added {
310 FileInclusion::Direct(_) => el.child(
311 h_flex()
312 .gap_1()
313 .child(
314 Icon::new(IconName::Check)
315 .size(IconSize::Small)
316 .color(Color::Success),
317 )
318 .child(Label::new("Added").size(LabelSize::Small)),
319 ),
320 FileInclusion::InDirectory(dir_name) => {
321 let dir_name = dir_name.to_string_lossy().into_owned();
322
323 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("Included").size(LabelSize::Small)),
332 )
333 .tooltip(move |cx| Tooltip::text(format!("in {dir_name}"), cx))
334 }
335 })
336}