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