1use std::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::{Picker, PickerDelegate};
10use project::Inventory;
11use task::{oneshot_source::OneshotSource, Task};
12use ui::{v_flex, HighlightedLabel, ListItem, ListItemSpacing, Selectable};
13use util::ResultExt;
14use workspace::{ModalView, Workspace};
15
16use crate::schedule_task;
17
18actions!(task, [Spawn, Rerun]);
19
20/// A modal used to spawn new tasks.
21pub(crate) struct TasksModalDelegate {
22 inventory: Model<Inventory>,
23 candidates: Vec<Arc<dyn Task>>,
24 matches: Vec<StringMatch>,
25 selected_index: usize,
26 placeholder_text: Arc<str>,
27 workspace: WeakView<Workspace>,
28 last_prompt: String,
29}
30
31impl TasksModalDelegate {
32 fn new(inventory: Model<Inventory>, workspace: WeakView<Workspace>) -> Self {
33 Self {
34 inventory,
35 workspace,
36 candidates: Vec::new(),
37 matches: Vec::new(),
38 selected_index: 0,
39 placeholder_text: Arc::from("Select task..."),
40 last_prompt: String::default(),
41 }
42 }
43
44 fn spawn_oneshot(&mut self, cx: &mut AppContext) -> Option<Arc<dyn Task>> {
45 let oneshot_source = self
46 .inventory
47 .update(cx, |this, _| this.source::<OneshotSource>())?;
48 oneshot_source.update(cx, |this, _| {
49 let Some(this) = this.as_any().downcast_mut::<OneshotSource>() else {
50 return None;
51 };
52 Some(this.spawn(self.last_prompt.clone()))
53 })
54 }
55}
56
57pub(crate) struct TasksModal {
58 picker: View<Picker<TasksModalDelegate>>,
59 _subscription: Subscription,
60}
61
62impl TasksModal {
63 pub(crate) fn new(
64 inventory: Model<Inventory>,
65 workspace: WeakView<Workspace>,
66 cx: &mut ViewContext<Self>,
67 ) -> Self {
68 let picker = cx
69 .new_view(|cx| Picker::uniform_list(TasksModalDelegate::new(inventory, workspace), cx));
70 let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
71 cx.emit(DismissEvent);
72 });
73 Self {
74 picker,
75 _subscription,
76 }
77 }
78}
79
80impl Render for TasksModal {
81 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl gpui::prelude::IntoElement {
82 v_flex()
83 .w(rems(34.))
84 .child(self.picker.clone())
85 .on_mouse_down_out(cx.listener(|modal, _, cx| {
86 modal.picker.update(cx, |picker, cx| {
87 picker.cancel(&Default::default(), cx);
88 })
89 }))
90 }
91}
92
93impl EventEmitter<DismissEvent> for TasksModal {}
94
95impl FocusableView for TasksModal {
96 fn focus_handle(&self, cx: &gpui::AppContext) -> gpui::FocusHandle {
97 self.picker.read(cx).focus_handle(cx)
98 }
99}
100
101impl ModalView for TasksModal {}
102
103impl PickerDelegate for TasksModalDelegate {
104 type ListItem = ListItem;
105
106 fn match_count(&self) -> usize {
107 self.matches.len()
108 }
109
110 fn selected_index(&self) -> usize {
111 self.selected_index
112 }
113
114 fn set_selected_index(&mut self, ix: usize, _cx: &mut ViewContext<picker::Picker<Self>>) {
115 self.selected_index = ix;
116 }
117
118 fn placeholder_text(&self) -> Arc<str> {
119 self.placeholder_text.clone()
120 }
121
122 fn update_matches(
123 &mut self,
124 query: String,
125 cx: &mut ViewContext<picker::Picker<Self>>,
126 ) -> gpui::Task<()> {
127 cx.spawn(move |picker, mut cx| async move {
128 let Some(candidates) = picker
129 .update(&mut cx, |picker, cx| {
130 picker.delegate.candidates = picker
131 .delegate
132 .inventory
133 .update(cx, |inventory, cx| inventory.list_tasks(None, cx));
134 picker
135 .delegate
136 .candidates
137 .sort_by(|a, b| a.name().cmp(&b.name()));
138
139 picker
140 .delegate
141 .candidates
142 .iter()
143 .enumerate()
144 .map(|(index, candidate)| StringMatchCandidate {
145 id: index,
146 char_bag: candidate.name().chars().collect(),
147 string: candidate.name().into(),
148 })
149 .collect::<Vec<_>>()
150 })
151 .ok()
152 else {
153 return;
154 };
155 let matches = fuzzy::match_strings(
156 &candidates,
157 &query,
158 true,
159 1000,
160 &Default::default(),
161 cx.background_executor().clone(),
162 )
163 .await;
164 picker
165 .update(&mut cx, |picker, _| {
166 let delegate = &mut picker.delegate;
167 delegate.matches = matches;
168 delegate.last_prompt = query;
169
170 if delegate.matches.is_empty() {
171 delegate.selected_index = 0;
172 } else {
173 delegate.selected_index =
174 delegate.selected_index.min(delegate.matches.len() - 1);
175 }
176 })
177 .log_err();
178 })
179 }
180
181 fn confirm(&mut self, secondary: bool, cx: &mut ViewContext<picker::Picker<Self>>) {
182 let current_match_index = self.selected_index();
183 let Some(task) = secondary
184 .then(|| self.spawn_oneshot(cx))
185 .flatten()
186 .or_else(|| {
187 self.matches.get(current_match_index).map(|current_match| {
188 let ix = current_match.candidate_id;
189 self.candidates[ix].clone()
190 })
191 })
192 else {
193 return;
194 };
195
196 self.workspace
197 .update(cx, |workspace, cx| {
198 schedule_task(workspace, task.as_ref(), cx);
199 })
200 .ok();
201 cx.emit(DismissEvent);
202 }
203
204 fn dismissed(&mut self, cx: &mut ViewContext<picker::Picker<Self>>) {
205 cx.emit(DismissEvent);
206 }
207
208 fn render_match(
209 &self,
210 ix: usize,
211 selected: bool,
212 _cx: &mut ViewContext<picker::Picker<Self>>,
213 ) -> Option<Self::ListItem> {
214 let hit = &self.matches[ix];
215 let highlights: Vec<_> = hit.positions.iter().copied().collect();
216 Some(
217 ListItem::new(SharedString::from(format!("tasks-modal-{ix}")))
218 .inset(true)
219 .spacing(ListItemSpacing::Sparse)
220 .selected(selected)
221 .start_slot(HighlightedLabel::new(hit.string.clone(), highlights)),
222 )
223 }
224}