1use futures::channel::oneshot;
2use fuzzy::{StringMatch, StringMatchCandidate};
3use picker::{Picker, PickerDelegate};
4use project::DirectoryLister;
5use std::{
6 path::{MAIN_SEPARATOR_STR, Path, PathBuf},
7 sync::{
8 Arc,
9 atomic::{self, AtomicBool},
10 },
11};
12use ui::{Context, ListItem, Window};
13use ui::{HighlightedLabel, ListItemSpacing, prelude::*};
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 string_matches: Vec<StringMatch>,
26 cancel_flag: Arc<AtomicBool>,
27 should_dismiss: bool,
28}
29
30impl OpenPathDelegate {
31 pub fn new(tx: oneshot::Sender<Option<Vec<PathBuf>>>, lister: DirectoryLister) -> Self {
32 Self {
33 tx: Some(tx),
34 lister,
35 selected_index: 0,
36 directory_state: None,
37 matches: Vec::new(),
38 string_matches: Vec::new(),
39 cancel_flag: Arc::new(AtomicBool::new(false)),
40 should_dismiss: true,
41 }
42 }
43
44 #[cfg(any(test, feature = "test-support"))]
45 pub fn collect_match_candidates(&self) -> Vec<String> {
46 if let Some(state) = self.directory_state.as_ref() {
47 self.matches
48 .iter()
49 .filter_map(|&index| {
50 state
51 .match_candidates
52 .get(index)
53 .map(|candidate| candidate.path.string.clone())
54 })
55 .collect()
56 } else {
57 Vec::new()
58 }
59 }
60}
61
62#[derive(Debug)]
63struct DirectoryState {
64 path: String,
65 match_candidates: Vec<CandidateInfo>,
66 error: Option<SharedString>,
67}
68
69#[derive(Debug, Clone)]
70struct CandidateInfo {
71 path: StringMatchCandidate,
72 is_dir: bool,
73}
74
75impl OpenPathPrompt {
76 pub(crate) fn register(
77 workspace: &mut Workspace,
78 _window: Option<&mut Window>,
79 _: &mut Context<Workspace>,
80 ) {
81 workspace.set_prompt_for_open_path(Box::new(|workspace, lister, window, cx| {
82 let (tx, rx) = futures::channel::oneshot::channel();
83 Self::prompt_for_open_path(workspace, lister, tx, window, cx);
84 rx
85 }));
86 }
87
88 fn prompt_for_open_path(
89 workspace: &mut Workspace,
90 lister: DirectoryLister,
91 tx: oneshot::Sender<Option<Vec<PathBuf>>>,
92 window: &mut Window,
93 cx: &mut Context<Workspace>,
94 ) {
95 workspace.toggle_modal(window, cx, |window, cx| {
96 let delegate = OpenPathDelegate::new(tx, lister.clone());
97
98 let picker = Picker::uniform_list(delegate, window, cx).width(rems(34.));
99 let query = lister.default_query(cx);
100 picker.set_query(query, window, cx);
101 picker
102 });
103 }
104}
105
106impl PickerDelegate for OpenPathDelegate {
107 type ListItem = ui::ListItem;
108
109 fn match_count(&self) -> usize {
110 self.matches.len()
111 }
112
113 fn selected_index(&self) -> usize {
114 self.selected_index
115 }
116
117 fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Picker<Self>>) {
118 self.selected_index = ix;
119 cx.notify();
120 }
121
122 fn update_matches(
123 &mut self,
124 query: String,
125 window: &mut Window,
126 cx: &mut Context<Picker<Self>>,
127 ) -> gpui::Task<()> {
128 let lister = self.lister.clone();
129 let query_path = Path::new(&query);
130 let last_item = query_path
131 .file_name()
132 .unwrap_or_default()
133 .to_string_lossy()
134 .to_string();
135 let (mut dir, suffix) = if let Some(dir) = query.strip_suffix(&last_item) {
136 (dir.to_string(), last_item)
137 } else {
138 (query, String::new())
139 };
140 if dir == "" {
141 #[cfg(not(target_os = "windows"))]
142 {
143 dir = "/".to_string();
144 }
145 #[cfg(target_os = "windows")]
146 {
147 dir = "C:\\".to_string();
148 }
149 }
150
151 let query = if self
152 .directory_state
153 .as_ref()
154 .map_or(false, |s| s.path == dir)
155 {
156 None
157 } else {
158 Some(lister.list_directory(dir.clone(), cx))
159 };
160 self.cancel_flag.store(true, atomic::Ordering::Relaxed);
161 self.cancel_flag = Arc::new(AtomicBool::new(false));
162 let cancel_flag = self.cancel_flag.clone();
163
164 cx.spawn_in(window, async move |this, cx| {
165 if let Some(query) = query {
166 let paths = query.await;
167 if cancel_flag.load(atomic::Ordering::Relaxed) {
168 return;
169 }
170
171 this.update(cx, |this, _| {
172 this.delegate.directory_state = Some(match paths {
173 Ok(mut paths) => {
174 paths.sort_by(|a, b| compare_paths((&a.path, true), (&b.path, true)));
175 let match_candidates = paths
176 .iter()
177 .enumerate()
178 .map(|(ix, item)| CandidateInfo {
179 path: StringMatchCandidate::new(
180 ix,
181 &item.path.to_string_lossy(),
182 ),
183 is_dir: item.is_dir,
184 })
185 .collect::<Vec<_>>();
186
187 DirectoryState {
188 match_candidates,
189 path: dir,
190 error: None,
191 }
192 }
193 Err(err) => DirectoryState {
194 match_candidates: vec![],
195 path: dir,
196 error: Some(err.to_string().into()),
197 },
198 });
199 })
200 .ok();
201 }
202
203 let match_candidates = this
204 .update(cx, |this, cx| {
205 let directory_state = this.delegate.directory_state.as_ref()?;
206 if directory_state.error.is_some() {
207 this.delegate.matches.clear();
208 this.delegate.selected_index = 0;
209 cx.notify();
210 return None;
211 }
212
213 Some(directory_state.match_candidates.clone())
214 })
215 .unwrap_or(None);
216
217 let Some(mut match_candidates) = match_candidates else {
218 return;
219 };
220
221 if !suffix.starts_with('.') {
222 match_candidates.retain(|m| !m.path.string.starts_with('.'));
223 }
224
225 if suffix == "" {
226 this.update(cx, |this, cx| {
227 this.delegate.matches.clear();
228 this.delegate.string_matches.clear();
229 this.delegate
230 .matches
231 .extend(match_candidates.iter().map(|m| m.path.id));
232
233 cx.notify();
234 })
235 .ok();
236 return;
237 }
238
239 let candidates = match_candidates.iter().map(|m| &m.path).collect::<Vec<_>>();
240 let matches = fuzzy::match_strings(
241 candidates.as_slice(),
242 &suffix,
243 false,
244 100,
245 &cancel_flag,
246 cx.background_executor().clone(),
247 )
248 .await;
249 if cancel_flag.load(atomic::Ordering::Relaxed) {
250 return;
251 }
252
253 this.update(cx, |this, cx| {
254 this.delegate.matches.clear();
255 this.delegate.string_matches = matches.clone();
256 this.delegate
257 .matches
258 .extend(matches.into_iter().map(|m| m.candidate_id));
259 this.delegate.matches.sort_by_key(|m| {
260 (
261 this.delegate.directory_state.as_ref().and_then(|d| {
262 d.match_candidates
263 .get(*m)
264 .map(|c| !c.path.string.starts_with(&suffix))
265 }),
266 *m,
267 )
268 });
269 this.delegate.selected_index = 0;
270 cx.notify();
271 })
272 .ok();
273 })
274 }
275
276 fn confirm_completion(
277 &mut self,
278 query: String,
279 _window: &mut Window,
280 _: &mut Context<Picker<Self>>,
281 ) -> Option<String> {
282 Some(
283 maybe!({
284 let m = self.matches.get(self.selected_index)?;
285 let directory_state = self.directory_state.as_ref()?;
286 let candidate = directory_state.match_candidates.get(*m)?;
287 Some(format!(
288 "{}{}{}",
289 directory_state.path,
290 candidate.path.string,
291 if candidate.is_dir {
292 MAIN_SEPARATOR_STR
293 } else {
294 ""
295 }
296 ))
297 })
298 .unwrap_or(query),
299 )
300 }
301
302 fn confirm(&mut self, _: bool, _: &mut Window, cx: &mut Context<Picker<Self>>) {
303 let Some(m) = self.matches.get(self.selected_index) else {
304 return;
305 };
306 let Some(directory_state) = self.directory_state.as_ref() else {
307 return;
308 };
309 let Some(candidate) = directory_state.match_candidates.get(*m) else {
310 return;
311 };
312 let result = Path::new(
313 self.lister
314 .resolve_tilde(&directory_state.path, cx)
315 .as_ref(),
316 )
317 .join(&candidate.path.string);
318 if let Some(tx) = self.tx.take() {
319 tx.send(Some(vec![result])).ok();
320 }
321 cx.emit(gpui::DismissEvent);
322 }
323
324 fn should_dismiss(&self) -> bool {
325 self.should_dismiss
326 }
327
328 fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
329 if let Some(tx) = self.tx.take() {
330 tx.send(None).ok();
331 }
332 cx.emit(gpui::DismissEvent)
333 }
334
335 fn render_match(
336 &self,
337 ix: usize,
338 selected: bool,
339 _window: &mut Window,
340 _: &mut Context<Picker<Self>>,
341 ) -> Option<Self::ListItem> {
342 let m = self.matches.get(ix)?;
343 let directory_state = self.directory_state.as_ref()?;
344 let candidate = directory_state.match_candidates.get(*m)?;
345 let highlight_positions = self
346 .string_matches
347 .iter()
348 .find(|string_match| string_match.candidate_id == *m)
349 .map(|string_match| string_match.positions.clone())
350 .unwrap_or_default();
351
352 Some(
353 ListItem::new(ix)
354 .spacing(ListItemSpacing::Sparse)
355 .inset(true)
356 .toggle_state(selected)
357 .child(HighlightedLabel::new(
358 candidate.path.string.clone(),
359 highlight_positions,
360 )),
361 )
362 }
363
364 fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
365 let text = if let Some(error) = self.directory_state.as_ref().and_then(|s| s.error.clone())
366 {
367 error
368 } else {
369 "No such file or directory".into()
370 };
371 Some(text)
372 }
373
374 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
375 Arc::from(format!("[directory{MAIN_SEPARATOR_STR}]filename.ext"))
376 }
377}