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