1use futures::channel::oneshot;
2use fuzzy::StringMatchCandidate;
3use picker::{Picker, PickerDelegate};
4use project::DirectoryLister;
5use std::{
6 path::{Path, PathBuf},
7 sync::{
8 atomic::{self, AtomicBool},
9 Arc,
10 },
11};
12use ui::{prelude::*, LabelLike, ListItemSpacing};
13use ui::{ListItem, ViewContext};
14use util::{maybe, paths::compare_paths};
15use workspace::Workspace;
16
17pub(crate) struct OpenPathPrompt;
18
19pub struct OpenPathDelegate {
20 tx: Option<oneshot::Sender<Option<Vec<PathBuf>>>>,
21 lister: DirectoryLister,
22 selected_index: usize,
23 directory_state: Option<DirectoryState>,
24 matches: Vec<usize>,
25 cancel_flag: Arc<AtomicBool>,
26 should_dismiss: bool,
27}
28
29impl OpenPathDelegate {
30 pub fn new(tx: oneshot::Sender<Option<Vec<PathBuf>>>, lister: DirectoryLister) -> Self {
31 Self {
32 tx: Some(tx),
33 lister,
34 selected_index: 0,
35 directory_state: None,
36 matches: Vec::new(),
37 cancel_flag: Arc::new(AtomicBool::new(false)),
38 should_dismiss: true,
39 }
40 }
41}
42
43struct DirectoryState {
44 path: String,
45 match_candidates: Vec<StringMatchCandidate>,
46 error: Option<SharedString>,
47}
48
49impl OpenPathPrompt {
50 pub(crate) fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
51 workspace.set_prompt_for_open_path(Box::new(|workspace, lister, cx| {
52 let (tx, rx) = futures::channel::oneshot::channel();
53 Self::prompt_for_open_path(workspace, lister, tx, cx);
54 rx
55 }));
56 }
57
58 fn prompt_for_open_path(
59 workspace: &mut Workspace,
60 lister: DirectoryLister,
61 tx: oneshot::Sender<Option<Vec<PathBuf>>>,
62 cx: &mut ViewContext<Workspace>,
63 ) {
64 workspace.toggle_modal(cx, |cx| {
65 let delegate = OpenPathDelegate::new(tx, lister.clone());
66
67 let picker = Picker::uniform_list(delegate, cx).width(rems(34.));
68 let query = lister.default_query(cx);
69 picker.set_query(query, cx);
70 picker
71 });
72 }
73}
74
75impl PickerDelegate for OpenPathDelegate {
76 type ListItem = ui::ListItem;
77
78 fn match_count(&self) -> usize {
79 self.matches.len()
80 }
81
82 fn selected_index(&self) -> usize {
83 self.selected_index
84 }
85
86 fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext<Picker<Self>>) {
87 self.selected_index = ix;
88 cx.notify();
89 }
90
91 fn update_matches(
92 &mut self,
93 query: String,
94 cx: &mut ViewContext<Picker<Self>>,
95 ) -> gpui::Task<()> {
96 let lister = self.lister.clone();
97 let (mut dir, suffix) = if let Some(index) = query.rfind('/') {
98 (query[..index].to_string(), query[index + 1..].to_string())
99 } else {
100 (query, String::new())
101 };
102 if dir == "" {
103 dir = "/".to_string();
104 }
105
106 let query = if self
107 .directory_state
108 .as_ref()
109 .map_or(false, |s| s.path == dir)
110 {
111 None
112 } else {
113 Some(lister.list_directory(dir.clone(), cx))
114 };
115 self.cancel_flag.store(true, atomic::Ordering::Relaxed);
116 self.cancel_flag = Arc::new(AtomicBool::new(false));
117 let cancel_flag = self.cancel_flag.clone();
118
119 cx.spawn(|this, mut cx| async move {
120 if let Some(query) = query {
121 let paths = query.await;
122 if cancel_flag.load(atomic::Ordering::Relaxed) {
123 return;
124 }
125
126 this.update(&mut cx, |this, _| {
127 this.delegate.directory_state = Some(match paths {
128 Ok(mut paths) => {
129 paths.sort_by(|a, b| compare_paths((a, true), (b, true)));
130 let match_candidates = paths
131 .iter()
132 .enumerate()
133 .map(|(ix, path)| {
134 StringMatchCandidate::new(ix, &path.to_string_lossy())
135 })
136 .collect::<Vec<_>>();
137
138 DirectoryState {
139 match_candidates,
140 path: dir,
141 error: None,
142 }
143 }
144 Err(err) => DirectoryState {
145 match_candidates: vec![],
146 path: dir,
147 error: Some(err.to_string().into()),
148 },
149 });
150 })
151 .ok();
152 }
153
154 let match_candidates = this
155 .update(&mut cx, |this, cx| {
156 let directory_state = this.delegate.directory_state.as_ref()?;
157 if directory_state.error.is_some() {
158 this.delegate.matches.clear();
159 this.delegate.selected_index = 0;
160 cx.notify();
161 return None;
162 }
163
164 Some(directory_state.match_candidates.clone())
165 })
166 .unwrap_or(None);
167
168 let Some(mut match_candidates) = match_candidates else {
169 return;
170 };
171
172 if !suffix.starts_with('.') {
173 match_candidates.retain(|m| !m.string.starts_with('.'));
174 }
175
176 if suffix == "" {
177 this.update(&mut cx, |this, cx| {
178 this.delegate.matches.clear();
179 this.delegate
180 .matches
181 .extend(match_candidates.iter().map(|m| m.id));
182
183 cx.notify();
184 })
185 .ok();
186 return;
187 }
188
189 let matches = fuzzy::match_strings(
190 match_candidates.as_slice(),
191 &suffix,
192 false,
193 100,
194 &cancel_flag,
195 cx.background_executor().clone(),
196 )
197 .await;
198 if cancel_flag.load(atomic::Ordering::Relaxed) {
199 return;
200 }
201
202 this.update(&mut cx, |this, cx| {
203 this.delegate.matches.clear();
204 this.delegate
205 .matches
206 .extend(matches.into_iter().map(|m| m.candidate_id));
207 this.delegate.matches.sort_by_key(|m| {
208 (
209 this.delegate.directory_state.as_ref().and_then(|d| {
210 d.match_candidates
211 .get(*m)
212 .map(|c| !c.string.starts_with(&suffix))
213 }),
214 *m,
215 )
216 });
217 cx.notify();
218 })
219 .ok();
220 })
221 }
222
223 fn confirm_completion(
224 &mut self,
225 query: String,
226 _: &mut ViewContext<Picker<Self>>,
227 ) -> Option<String> {
228 Some(
229 maybe!({
230 let m = self.matches.get(self.selected_index)?;
231 let directory_state = self.directory_state.as_ref()?;
232 let candidate = directory_state.match_candidates.get(*m)?;
233 Some(format!("{}/{}", directory_state.path, candidate.string))
234 })
235 .unwrap_or(query),
236 )
237 }
238
239 fn confirm(&mut self, _: bool, cx: &mut ViewContext<Picker<Self>>) {
240 let Some(m) = self.matches.get(self.selected_index) else {
241 return;
242 };
243 let Some(directory_state) = self.directory_state.as_ref() else {
244 return;
245 };
246 let Some(candidate) = directory_state.match_candidates.get(*m) else {
247 return;
248 };
249 let result = Path::new(
250 self.lister
251 .resolve_tilde(&directory_state.path, cx)
252 .as_ref(),
253 )
254 .join(&candidate.string);
255 if let Some(tx) = self.tx.take() {
256 tx.send(Some(vec![result])).ok();
257 }
258 cx.emit(gpui::DismissEvent);
259 }
260
261 fn should_dismiss(&self) -> bool {
262 self.should_dismiss
263 }
264
265 fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
266 if let Some(tx) = self.tx.take() {
267 tx.send(None).ok();
268 }
269 cx.emit(gpui::DismissEvent)
270 }
271
272 fn render_match(
273 &self,
274 ix: usize,
275 selected: bool,
276 _: &mut ViewContext<Picker<Self>>,
277 ) -> Option<Self::ListItem> {
278 let m = self.matches.get(ix)?;
279 let directory_state = self.directory_state.as_ref()?;
280 let candidate = directory_state.match_candidates.get(*m)?;
281
282 Some(
283 ListItem::new(ix)
284 .spacing(ListItemSpacing::Sparse)
285 .inset(true)
286 .toggle_state(selected)
287 .child(LabelLike::new().child(candidate.string.clone())),
288 )
289 }
290
291 fn no_matches_text(&self, _cx: &mut WindowContext) -> SharedString {
292 if let Some(error) = self.directory_state.as_ref().and_then(|s| s.error.clone()) {
293 error
294 } else {
295 "No such file or directory".into()
296 }
297 }
298
299 fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc<str> {
300 Arc::from("[directory/]filename.ext")
301 }
302}