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