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