1use futures::channel::oneshot;
2use fuzzy::{StringMatch, StringMatchCandidate};
3use picker::{Picker, PickerDelegate};
4use project::{DirectoryItem, 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
141 if dir == "" {
142 #[cfg(not(target_os = "windows"))]
143 {
144 dir = "/".to_string();
145 }
146 #[cfg(target_os = "windows")]
147 {
148 dir = "C:\\".to_string();
149 }
150 }
151
152 let query = if self
153 .directory_state
154 .as_ref()
155 .map_or(false, |s| s.path == dir)
156 {
157 None
158 } else {
159 Some(lister.list_directory(dir.clone(), cx))
160 };
161 self.cancel_flag.store(true, atomic::Ordering::Relaxed);
162 self.cancel_flag = Arc::new(AtomicBool::new(false));
163 let cancel_flag = self.cancel_flag.clone();
164
165 cx.spawn_in(window, async move |this, cx| {
166 if let Some(query) = query {
167 let paths = query.await;
168 if cancel_flag.load(atomic::Ordering::Relaxed) {
169 return;
170 }
171
172 this.update(cx, |this, _| {
173 this.delegate.directory_state = Some(match paths {
174 Ok(mut paths) => {
175 if dir == "/" {
176 paths.push(DirectoryItem {
177 is_dir: true,
178 path: Default::default(),
179 });
180 }
181
182 paths.sort_by(|a, b| compare_paths((&a.path, true), (&b.path, true)));
183 let match_candidates = paths
184 .iter()
185 .enumerate()
186 .map(|(ix, item)| CandidateInfo {
187 path: StringMatchCandidate::new(
188 ix,
189 &item.path.to_string_lossy(),
190 ),
191 is_dir: item.is_dir,
192 })
193 .collect::<Vec<_>>();
194
195 DirectoryState {
196 match_candidates,
197 path: dir,
198 error: None,
199 }
200 }
201 Err(err) => DirectoryState {
202 match_candidates: vec![],
203 path: dir,
204 error: Some(err.to_string().into()),
205 },
206 });
207 })
208 .ok();
209 }
210
211 let match_candidates = this
212 .update(cx, |this, cx| {
213 let directory_state = this.delegate.directory_state.as_ref()?;
214 if directory_state.error.is_some() {
215 this.delegate.matches.clear();
216 this.delegate.selected_index = 0;
217 cx.notify();
218 return None;
219 }
220
221 Some(directory_state.match_candidates.clone())
222 })
223 .unwrap_or(None);
224
225 let Some(mut match_candidates) = match_candidates else {
226 return;
227 };
228
229 if !suffix.starts_with('.') {
230 match_candidates.retain(|m| !m.path.string.starts_with('.'));
231 }
232
233 if suffix == "" {
234 this.update(cx, |this, cx| {
235 this.delegate.matches.clear();
236 this.delegate.string_matches.clear();
237 this.delegate
238 .matches
239 .extend(match_candidates.iter().map(|m| m.path.id));
240
241 cx.notify();
242 })
243 .ok();
244 return;
245 }
246
247 let candidates = match_candidates.iter().map(|m| &m.path).collect::<Vec<_>>();
248 let matches = fuzzy::match_strings(
249 candidates.as_slice(),
250 &suffix,
251 false,
252 100,
253 &cancel_flag,
254 cx.background_executor().clone(),
255 )
256 .await;
257 if cancel_flag.load(atomic::Ordering::Relaxed) {
258 return;
259 }
260
261 this.update(cx, |this, cx| {
262 this.delegate.matches.clear();
263 this.delegate.string_matches = matches.clone();
264 this.delegate
265 .matches
266 .extend(matches.into_iter().map(|m| m.candidate_id));
267 this.delegate.matches.sort_by_key(|m| {
268 (
269 this.delegate.directory_state.as_ref().and_then(|d| {
270 d.match_candidates
271 .get(*m)
272 .map(|c| !c.path.string.starts_with(&suffix))
273 }),
274 *m,
275 )
276 });
277 this.delegate.selected_index = 0;
278 cx.notify();
279 })
280 .ok();
281 })
282 }
283
284 fn confirm_completion(
285 &mut self,
286 query: String,
287 _window: &mut Window,
288 _: &mut Context<Picker<Self>>,
289 ) -> Option<String> {
290 Some(
291 maybe!({
292 let m = self.matches.get(self.selected_index)?;
293 let directory_state = self.directory_state.as_ref()?;
294 let candidate = directory_state.match_candidates.get(*m)?;
295 Some(format!(
296 "{}{}{}",
297 directory_state.path,
298 candidate.path.string,
299 if candidate.is_dir {
300 MAIN_SEPARATOR_STR
301 } else {
302 ""
303 }
304 ))
305 })
306 .unwrap_or(query),
307 )
308 }
309
310 fn confirm(&mut self, _: bool, _: &mut Window, cx: &mut Context<Picker<Self>>) {
311 let Some(m) = self.matches.get(self.selected_index) else {
312 return;
313 };
314 let Some(directory_state) = self.directory_state.as_ref() else {
315 return;
316 };
317 let Some(candidate) = directory_state.match_candidates.get(*m) else {
318 return;
319 };
320 let result = if directory_state.path == "/" && candidate.path.string.is_empty() {
321 PathBuf::from("/")
322 } else {
323 Path::new(
324 self.lister
325 .resolve_tilde(&directory_state.path, cx)
326 .as_ref(),
327 )
328 .join(&candidate.path.string)
329 };
330 if let Some(tx) = self.tx.take() {
331 tx.send(Some(vec![result])).ok();
332 }
333 cx.emit(gpui::DismissEvent);
334 }
335
336 fn should_dismiss(&self) -> bool {
337 self.should_dismiss
338 }
339
340 fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
341 if let Some(tx) = self.tx.take() {
342 tx.send(None).ok();
343 }
344 cx.emit(gpui::DismissEvent)
345 }
346
347 fn render_match(
348 &self,
349 ix: usize,
350 selected: bool,
351 _window: &mut Window,
352 _: &mut Context<Picker<Self>>,
353 ) -> Option<Self::ListItem> {
354 let m = self.matches.get(ix)?;
355 let directory_state = self.directory_state.as_ref()?;
356 let candidate = directory_state.match_candidates.get(*m)?;
357 let highlight_positions = self
358 .string_matches
359 .iter()
360 .find(|string_match| string_match.candidate_id == *m)
361 .map(|string_match| string_match.positions.clone())
362 .unwrap_or_default();
363
364 Some(
365 ListItem::new(ix)
366 .spacing(ListItemSpacing::Sparse)
367 .inset(true)
368 .toggle_state(selected)
369 .child(HighlightedLabel::new(
370 if directory_state.path == "/" {
371 format!("/{}", candidate.path.string)
372 } else {
373 candidate.path.string.clone()
374 },
375 highlight_positions,
376 )),
377 )
378 }
379
380 fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
381 let text = if let Some(error) = self.directory_state.as_ref().and_then(|s| s.error.clone())
382 {
383 error
384 } else {
385 "No such file or directory".into()
386 };
387 Some(text)
388 }
389
390 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
391 Arc::from(format!("[directory{MAIN_SEPARATOR_STR}]filename.ext"))
392 }
393}