1use std::{path::PathBuf, sync::Arc};
2
3use fuzzy::{StringMatch, StringMatchCandidate};
4use gpui::{
5 actions, rems, AppContext, DismissEvent, EventEmitter, FocusableView, InteractiveElement,
6 Model, ParentElement, Render, SharedString, Styled, Subscription, View, ViewContext,
7 VisualContext, WeakView,
8};
9use picker::{
10 highlighted_match_with_paths::{HighlightedMatchWithPaths, HighlightedText},
11 Picker, PickerDelegate,
12};
13use project::{Inventory, ProjectPath, TaskSourceKind};
14use task::{oneshot_source::OneshotSource, Task};
15use ui::{v_flex, ListItem, ListItemSpacing, RenderOnce, Selectable, WindowContext};
16use util::{paths::PathExt, ResultExt};
17use workspace::{ModalView, Workspace};
18
19use crate::schedule_task;
20
21actions!(task, [Spawn, Rerun]);
22
23/// A modal used to spawn new tasks.
24pub(crate) struct TasksModalDelegate {
25 inventory: Model<Inventory>,
26 candidates: Vec<(TaskSourceKind, Arc<dyn Task>)>,
27 matches: Vec<StringMatch>,
28 selected_index: usize,
29 workspace: WeakView<Workspace>,
30 prompt: String,
31}
32
33impl TasksModalDelegate {
34 fn new(inventory: Model<Inventory>, workspace: WeakView<Workspace>) -> Self {
35 Self {
36 inventory,
37 workspace,
38 candidates: Vec::new(),
39 matches: Vec::new(),
40 selected_index: 0,
41 prompt: String::default(),
42 }
43 }
44
45 fn spawn_oneshot(&mut self, cx: &mut AppContext) -> Option<Arc<dyn Task>> {
46 self.inventory
47 .update(cx, |inventory, _| inventory.source::<OneshotSource>())?
48 .update(cx, |oneshot_source, _| {
49 Some(
50 oneshot_source
51 .as_any()
52 .downcast_mut::<OneshotSource>()?
53 .spawn(self.prompt.clone()),
54 )
55 })
56 }
57
58 fn active_item_path(
59 &mut self,
60 cx: &mut ViewContext<'_, Picker<Self>>,
61 ) -> Option<(PathBuf, ProjectPath)> {
62 let workspace = self.workspace.upgrade()?.read(cx);
63 let project = workspace.project().read(cx);
64 let active_item = workspace.active_item(cx)?;
65 active_item.project_path(cx).and_then(|project_path| {
66 project
67 .worktree_for_id(project_path.worktree_id, cx)
68 .map(|worktree| worktree.read(cx).abs_path().join(&project_path.path))
69 .zip(Some(project_path))
70 })
71 }
72}
73
74pub(crate) struct TasksModal {
75 picker: View<Picker<TasksModalDelegate>>,
76 _subscription: Subscription,
77}
78
79impl TasksModal {
80 pub(crate) fn new(
81 inventory: Model<Inventory>,
82 workspace: WeakView<Workspace>,
83 cx: &mut ViewContext<Self>,
84 ) -> Self {
85 let picker = cx
86 .new_view(|cx| Picker::uniform_list(TasksModalDelegate::new(inventory, workspace), cx));
87 let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
88 cx.emit(DismissEvent);
89 });
90 Self {
91 picker,
92 _subscription,
93 }
94 }
95}
96
97impl Render for TasksModal {
98 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl gpui::prelude::IntoElement {
99 v_flex()
100 .w(rems(34.))
101 .child(self.picker.clone())
102 .on_mouse_down_out(cx.listener(|modal, _, cx| {
103 modal.picker.update(cx, |picker, cx| {
104 picker.cancel(&Default::default(), cx);
105 })
106 }))
107 }
108}
109
110impl EventEmitter<DismissEvent> for TasksModal {}
111
112impl FocusableView for TasksModal {
113 fn focus_handle(&self, cx: &gpui::AppContext) -> gpui::FocusHandle {
114 self.picker.read(cx).focus_handle(cx)
115 }
116}
117
118impl ModalView for TasksModal {}
119
120impl PickerDelegate for TasksModalDelegate {
121 type ListItem = ListItem;
122
123 fn match_count(&self) -> usize {
124 self.matches.len()
125 }
126
127 fn selected_index(&self) -> usize {
128 self.selected_index
129 }
130
131 fn set_selected_index(&mut self, ix: usize, _cx: &mut ViewContext<picker::Picker<Self>>) {
132 self.selected_index = ix;
133 }
134
135 fn placeholder_text(&self, cx: &mut WindowContext) -> Arc<str> {
136 Arc::from(format!(
137 "{} runs the selected task, {} spawns a bash-like task from the prompt",
138 cx.keystroke_text_for(&menu::Confirm),
139 cx.keystroke_text_for(&menu::SecondaryConfirm),
140 ))
141 }
142
143 fn update_matches(
144 &mut self,
145 query: String,
146 cx: &mut ViewContext<picker::Picker<Self>>,
147 ) -> gpui::Task<()> {
148 cx.spawn(move |picker, mut cx| async move {
149 let Some(candidates) = picker
150 .update(&mut cx, |picker, cx| {
151 let (path, worktree) = match picker.delegate.active_item_path(cx) {
152 Some((abs_path, project_path)) => {
153 (Some(abs_path), Some(project_path.worktree_id))
154 }
155 None => (None, None),
156 };
157 picker.delegate.candidates =
158 picker.delegate.inventory.update(cx, |inventory, cx| {
159 inventory.list_tasks(path.as_deref(), worktree, true, cx)
160 });
161 picker
162 .delegate
163 .candidates
164 .iter()
165 .enumerate()
166 .map(|(index, (_, candidate))| StringMatchCandidate {
167 id: index,
168 char_bag: candidate.name().chars().collect(),
169 string: candidate.name().into(),
170 })
171 .collect::<Vec<_>>()
172 })
173 .ok()
174 else {
175 return;
176 };
177 let matches = fuzzy::match_strings(
178 &candidates,
179 &query,
180 true,
181 1000,
182 &Default::default(),
183 cx.background_executor().clone(),
184 )
185 .await;
186 picker
187 .update(&mut cx, |picker, _| {
188 let delegate = &mut picker.delegate;
189 delegate.matches = matches;
190 delegate.prompt = query;
191
192 if delegate.matches.is_empty() {
193 delegate.selected_index = 0;
194 } else {
195 delegate.selected_index =
196 delegate.selected_index.min(delegate.matches.len() - 1);
197 }
198 })
199 .log_err();
200 })
201 }
202
203 fn confirm(&mut self, secondary: bool, cx: &mut ViewContext<picker::Picker<Self>>) {
204 let current_match_index = self.selected_index();
205 let task = if secondary {
206 if !self.prompt.trim().is_empty() {
207 self.spawn_oneshot(cx)
208 } else {
209 None
210 }
211 } else {
212 self.matches.get(current_match_index).map(|current_match| {
213 let ix = current_match.candidate_id;
214 self.candidates[ix].1.clone()
215 })
216 };
217
218 let Some(task) = task else {
219 return;
220 };
221
222 self.workspace
223 .update(cx, |workspace, cx| {
224 schedule_task(workspace, task.as_ref(), cx);
225 })
226 .ok();
227 cx.emit(DismissEvent);
228 }
229
230 fn dismissed(&mut self, cx: &mut ViewContext<picker::Picker<Self>>) {
231 cx.emit(DismissEvent);
232 }
233
234 fn render_match(
235 &self,
236 ix: usize,
237 selected: bool,
238 cx: &mut ViewContext<picker::Picker<Self>>,
239 ) -> Option<Self::ListItem> {
240 let hit = &self.matches[ix];
241 let (source_kind, _) = &self.candidates[hit.candidate_id];
242 let details = match source_kind {
243 TaskSourceKind::UserInput => "user input".to_string(),
244 TaskSourceKind::Worktree { abs_path, .. } | TaskSourceKind::AbsPath(abs_path) => {
245 abs_path.compact().to_string_lossy().to_string()
246 }
247 };
248
249 let highlighted_location = HighlightedMatchWithPaths {
250 match_label: HighlightedText {
251 text: hit.string.clone(),
252 highlight_positions: hit.positions.clone(),
253 char_count: hit.string.chars().count(),
254 },
255 paths: vec![HighlightedText {
256 char_count: details.chars().count(),
257 highlight_positions: Vec::new(),
258 text: details,
259 }],
260 };
261 Some(
262 ListItem::new(SharedString::from(format!("tasks-modal-{ix}")))
263 .inset(true)
264 .spacing(ListItemSpacing::Sparse)
265 .selected(selected)
266 .child(highlighted_location.render(cx)),
267 )
268 }
269}