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