recent_projects.rs

  1pub mod disconnected_overlay;
  2mod remote_servers;
  3mod ssh_connections;
  4use remote::SshConnectionOptions;
  5pub use ssh_connections::open_ssh_project;
  6
  7use client::{DevServerProjectId, ProjectId};
  8use disconnected_overlay::DisconnectedOverlay;
  9use fuzzy::{StringMatch, StringMatchCandidate};
 10use gpui::{
 11    Action, AnyElement, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView,
 12    Subscription, Task, View, ViewContext, WeakView,
 13};
 14use itertools::Itertools;
 15use ordered_float::OrderedFloat;
 16use picker::{
 17    highlighted_match_with_paths::{HighlightedMatchWithPaths, HighlightedText},
 18    Picker, PickerDelegate,
 19};
 20use remote_servers::reconnect_to_dev_server_project;
 21pub use remote_servers::RemoteServerProjects;
 22use rpc::proto::DevServerStatus;
 23use serde::Deserialize;
 24use settings::Settings;
 25pub use ssh_connections::SshSettings;
 26use std::{
 27    path::{Path, PathBuf},
 28    sync::Arc,
 29};
 30use ui::{
 31    prelude::*, tooltip_container, ButtonLike, IconWithIndicator, Indicator, KeyBinding, ListItem,
 32    ListItemSpacing, Tooltip,
 33};
 34use util::{paths::PathExt, ResultExt};
 35use workspace::{
 36    AppState, CloseIntent, ModalView, OpenOptions, SerializedWorkspaceLocation, Workspace,
 37    WorkspaceId, WORKSPACE_DB,
 38};
 39
 40#[derive(PartialEq, Clone, Deserialize, Default)]
 41pub struct OpenRecent {
 42    #[serde(default = "default_create_new_window")]
 43    pub create_new_window: bool,
 44}
 45
 46fn default_create_new_window() -> bool {
 47    true
 48}
 49
 50gpui::impl_actions!(projects, [OpenRecent]);
 51gpui::actions!(projects, [OpenRemote]);
 52
 53pub fn init(cx: &mut AppContext) {
 54    SshSettings::register(cx);
 55    cx.observe_new_views(RecentProjects::register).detach();
 56    cx.observe_new_views(RemoteServerProjects::register)
 57        .detach();
 58    cx.observe_new_views(DisconnectedOverlay::register).detach();
 59}
 60
 61pub struct RecentProjects {
 62    pub picker: View<Picker<RecentProjectsDelegate>>,
 63    rem_width: f32,
 64    _subscription: Subscription,
 65}
 66
 67impl ModalView for RecentProjects {}
 68
 69impl RecentProjects {
 70    fn new(delegate: RecentProjectsDelegate, rem_width: f32, cx: &mut ViewContext<Self>) -> Self {
 71        let picker = cx.new_view(|cx| {
 72            // We want to use a list when we render paths, because the items can have different heights (multiple paths).
 73            if delegate.render_paths {
 74                Picker::list(delegate, cx)
 75            } else {
 76                Picker::uniform_list(delegate, cx)
 77            }
 78        });
 79        let _subscription = cx.subscribe(&picker, |_, _, _, cx| cx.emit(DismissEvent));
 80        // We do not want to block the UI on a potentially lengthy call to DB, so we're gonna swap
 81        // out workspace locations once the future runs to completion.
 82        cx.spawn(|this, mut cx| async move {
 83            let workspaces = WORKSPACE_DB
 84                .recent_workspaces_on_disk()
 85                .await
 86                .log_err()
 87                .unwrap_or_default();
 88            this.update(&mut cx, move |this, cx| {
 89                this.picker.update(cx, move |picker, cx| {
 90                    picker.delegate.set_workspaces(workspaces);
 91                    picker.update_matches(picker.query(cx), cx)
 92                })
 93            })
 94            .ok()
 95        })
 96        .detach();
 97        Self {
 98            picker,
 99            rem_width,
100            _subscription,
101        }
102    }
103
104    fn register(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) {
105        workspace.register_action(|workspace, open_recent: &OpenRecent, cx| {
106            let Some(recent_projects) = workspace.active_modal::<Self>(cx) else {
107                Self::open(workspace, open_recent.create_new_window, cx);
108                return;
109            };
110
111            recent_projects.update(cx, |recent_projects, cx| {
112                recent_projects
113                    .picker
114                    .update(cx, |picker, cx| picker.cycle_selection(cx))
115            });
116        });
117        if workspace
118            .project()
119            .read(cx)
120            .dev_server_project_id()
121            .is_some()
122        {
123            workspace.register_action(|workspace, _: &workspace::Open, cx| {
124                if workspace.active_modal::<Self>(cx).is_some() {
125                    cx.propagate();
126                } else {
127                    Self::open(workspace, true, cx);
128                }
129            });
130        }
131    }
132
133    pub fn open(
134        workspace: &mut Workspace,
135        create_new_window: bool,
136        cx: &mut ViewContext<Workspace>,
137    ) {
138        let weak = cx.view().downgrade();
139        workspace.toggle_modal(cx, |cx| {
140            let delegate = RecentProjectsDelegate::new(weak, create_new_window, true);
141
142            Self::new(delegate, 34., cx)
143        })
144    }
145}
146
147impl EventEmitter<DismissEvent> for RecentProjects {}
148
149impl FocusableView for RecentProjects {
150    fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
151        self.picker.focus_handle(cx)
152    }
153}
154
155impl Render for RecentProjects {
156    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
157        v_flex()
158            .w(rems(self.rem_width))
159            .child(self.picker.clone())
160            .on_mouse_down_out(cx.listener(|this, _, cx| {
161                this.picker.update(cx, |this, cx| {
162                    this.cancel(&Default::default(), cx);
163                })
164            }))
165    }
166}
167
168pub struct RecentProjectsDelegate {
169    workspace: WeakView<Workspace>,
170    workspaces: Vec<(WorkspaceId, SerializedWorkspaceLocation)>,
171    selected_match_index: usize,
172    matches: Vec<StringMatch>,
173    render_paths: bool,
174    create_new_window: bool,
175    // Flag to reset index when there is a new query vs not reset index when user delete an item
176    reset_selected_match_index: bool,
177    has_any_non_local_projects: bool,
178}
179
180impl RecentProjectsDelegate {
181    fn new(workspace: WeakView<Workspace>, create_new_window: bool, render_paths: bool) -> Self {
182        Self {
183            workspace,
184            workspaces: Vec::new(),
185            selected_match_index: 0,
186            matches: Default::default(),
187            create_new_window,
188            render_paths,
189            reset_selected_match_index: true,
190            has_any_non_local_projects: false,
191        }
192    }
193
194    pub fn set_workspaces(&mut self, workspaces: Vec<(WorkspaceId, SerializedWorkspaceLocation)>) {
195        self.workspaces = workspaces;
196        self.has_any_non_local_projects = !self
197            .workspaces
198            .iter()
199            .all(|(_, location)| matches!(location, SerializedWorkspaceLocation::Local(_, _)));
200    }
201}
202impl EventEmitter<DismissEvent> for RecentProjectsDelegate {}
203impl PickerDelegate for RecentProjectsDelegate {
204    type ListItem = ListItem;
205
206    fn placeholder_text(&self, cx: &mut WindowContext) -> Arc<str> {
207        let (create_window, reuse_window) = if self.create_new_window {
208            (
209                cx.keystroke_text_for(&menu::Confirm),
210                cx.keystroke_text_for(&menu::SecondaryConfirm),
211            )
212        } else {
213            (
214                cx.keystroke_text_for(&menu::SecondaryConfirm),
215                cx.keystroke_text_for(&menu::Confirm),
216            )
217        };
218        Arc::from(format!(
219            "{reuse_window} reuses this window, {create_window} opens a new one",
220        ))
221    }
222
223    fn match_count(&self) -> usize {
224        self.matches.len()
225    }
226
227    fn selected_index(&self) -> usize {
228        self.selected_match_index
229    }
230
231    fn set_selected_index(&mut self, ix: usize, _cx: &mut ViewContext<Picker<Self>>) {
232        self.selected_match_index = ix;
233    }
234
235    fn update_matches(
236        &mut self,
237        query: String,
238        cx: &mut ViewContext<Picker<Self>>,
239    ) -> gpui::Task<()> {
240        let query = query.trim_start();
241        let smart_case = query.chars().any(|c| c.is_uppercase());
242        let candidates = self
243            .workspaces
244            .iter()
245            .enumerate()
246            .filter(|(_, (id, _))| !self.is_current_workspace(*id, cx))
247            .map(|(id, (_, location))| {
248                let combined_string = match location {
249                    SerializedWorkspaceLocation::Local(paths, order) => order
250                        .order()
251                        .iter()
252                        .zip(paths.paths().iter())
253                        .sorted_by_key(|(i, _)| *i)
254                        .map(|(_, path)| path.compact().to_string_lossy().into_owned())
255                        .collect::<Vec<_>>()
256                        .join(""),
257                    SerializedWorkspaceLocation::DevServer(dev_server_project) => {
258                        format!(
259                            "{}{}",
260                            dev_server_project.dev_server_name,
261                            dev_server_project.paths.join("")
262                        )
263                    }
264                    SerializedWorkspaceLocation::Ssh(ssh_project) => ssh_project
265                        .ssh_urls()
266                        .iter()
267                        .map(|path| path.to_string_lossy().to_string())
268                        .collect::<Vec<_>>()
269                        .join(""),
270                };
271
272                StringMatchCandidate::new(id, combined_string)
273            })
274            .collect::<Vec<_>>();
275        self.matches = smol::block_on(fuzzy::match_strings(
276            candidates.as_slice(),
277            query,
278            smart_case,
279            100,
280            &Default::default(),
281            cx.background_executor().clone(),
282        ));
283        self.matches.sort_unstable_by_key(|m| m.candidate_id);
284
285        if self.reset_selected_match_index {
286            self.selected_match_index = self
287                .matches
288                .iter()
289                .enumerate()
290                .rev()
291                .max_by_key(|(_, m)| OrderedFloat(m.score))
292                .map(|(ix, _)| ix)
293                .unwrap_or(0);
294        }
295        self.reset_selected_match_index = true;
296        Task::ready(())
297    }
298
299    fn confirm(&mut self, secondary: bool, cx: &mut ViewContext<Picker<Self>>) {
300        if let Some((selected_match, workspace)) = self
301            .matches
302            .get(self.selected_index())
303            .zip(self.workspace.upgrade())
304        {
305            let (candidate_workspace_id, candidate_workspace_location) =
306                &self.workspaces[selected_match.candidate_id];
307            let replace_current_window = if self.create_new_window {
308                secondary
309            } else {
310                !secondary
311            };
312            workspace
313                .update(cx, |workspace, cx| {
314                    if workspace.database_id() == Some(*candidate_workspace_id) {
315                        Task::ready(Ok(()))
316                    } else {
317                        match candidate_workspace_location {
318                            SerializedWorkspaceLocation::Local(paths, _) => {
319                                let paths = paths.paths().to_vec();
320                                if replace_current_window {
321                                    cx.spawn(move |workspace, mut cx| async move {
322                                        let continue_replacing = workspace
323                                            .update(&mut cx, |workspace, cx| {
324                                                workspace.prepare_to_close(CloseIntent::ReplaceWindow, cx)
325                                            })?
326                                            .await?;
327                                        if continue_replacing {
328                                            workspace
329                                                .update(&mut cx, |workspace, cx| {
330                                                    workspace
331                                                        .open_workspace_for_paths(true, paths, cx)
332                                                })?
333                                                .await
334                                        } else {
335                                            Ok(())
336                                        }
337                                    })
338                                } else {
339                                    workspace.open_workspace_for_paths(false, paths, cx)
340                                }
341                            }
342                            SerializedWorkspaceLocation::DevServer(dev_server_project) => {
343                                let store = dev_server_projects::Store::global(cx);
344                                let Some(project_id) = store.read(cx)
345                                    .dev_server_project(dev_server_project.id)
346                                    .and_then(|p| p.project_id)
347                                else {
348                                    let server = store.read(cx).dev_server_for_project(dev_server_project.id);
349                                    if server.is_some_and(|server| server.ssh_connection_string.is_some()) {
350                                        return reconnect_to_dev_server_project(cx.view().clone(), server.unwrap().clone(), dev_server_project.id, replace_current_window, cx);
351                                    } else {
352                                        let dev_server_name = dev_server_project.dev_server_name.clone();
353                                        return cx.spawn(|workspace, mut cx| async move {
354                                            let response =
355                                                cx.prompt(gpui::PromptLevel::Warning,
356                                                    "Dev Server is offline",
357                                                    Some(format!("Cannot connect to {}. To debug open the remote project settings.", dev_server_name).as_str()),
358                                                    &["Ok", "Open Settings"]
359                                                ).await?;
360                                            if response == 1 {
361                                                workspace.update(&mut cx, |workspace, cx| {
362                                                    let handle = cx.view().downgrade();
363                                                    workspace.toggle_modal(cx, |cx| RemoteServerProjects::new(cx, handle))
364                                                })?;
365                                            } else {
366                                                workspace.update(&mut cx, |workspace, cx| {
367                                                    RecentProjects::open(workspace, true, cx);
368                                                })?;
369                                            }
370                                            Ok(())
371                                        })
372                                    }
373                                };
374                                open_dev_server_project(replace_current_window, dev_server_project.id, project_id, cx)
375                        }
376                        SerializedWorkspaceLocation::Ssh(ssh_project) => {
377                            let app_state = workspace.app_state().clone();
378
379                            let replace_window = if replace_current_window {
380                                cx.window_handle().downcast::<Workspace>()
381                            } else {
382                                None
383                            };
384
385                            let open_options = OpenOptions {
386                                replace_window,
387                                ..Default::default()
388                            };
389
390                            let args = SshSettings::get_global(cx).args_for(&ssh_project.host, ssh_project.port, &ssh_project.user);
391                            let connection_options = SshConnectionOptions {
392                                host: ssh_project.host.clone(),
393                                username: ssh_project.user.clone(),
394                                port: ssh_project.port,
395                                password: None,
396                                args,
397                            };
398
399                            let paths = ssh_project.paths.iter().map(PathBuf::from).collect();
400
401                            cx.spawn(|_, mut cx| async move {
402                                open_ssh_project(connection_options, paths, app_state, open_options, &mut cx).await
403                            })
404                        }
405                    }
406                }
407                })
408            .detach_and_log_err(cx);
409            cx.emit(DismissEvent);
410        }
411    }
412
413    fn dismissed(&mut self, _: &mut ViewContext<Picker<Self>>) {}
414
415    fn no_matches_text(&self, _cx: &mut WindowContext) -> SharedString {
416        if self.workspaces.is_empty() {
417            "Recently opened projects will show up here".into()
418        } else {
419            "No matches".into()
420        }
421    }
422
423    fn render_match(
424        &self,
425        ix: usize,
426        selected: bool,
427        cx: &mut ViewContext<Picker<Self>>,
428    ) -> Option<Self::ListItem> {
429        let hit = self.matches.get(ix)?;
430
431        let (_, location) = self.workspaces.get(hit.candidate_id)?;
432
433        let dev_server_status =
434            if let SerializedWorkspaceLocation::DevServer(dev_server_project) = location {
435                let store = dev_server_projects::Store::global(cx).read(cx);
436                Some(
437                    store
438                        .dev_server_project(dev_server_project.id)
439                        .and_then(|p| store.dev_server(p.dev_server_id))
440                        .map(|s| s.status)
441                        .unwrap_or_default(),
442                )
443            } else {
444                None
445            };
446
447        let mut path_start_offset = 0;
448        let paths = match location {
449            SerializedWorkspaceLocation::Local(paths, order) => Arc::new(
450                order
451                    .order()
452                    .iter()
453                    .zip(paths.paths().iter())
454                    .sorted_by_key(|(i, _)| **i)
455                    .map(|(_, path)| path.compact())
456                    .collect(),
457            ),
458            SerializedWorkspaceLocation::Ssh(ssh_project) => Arc::new(ssh_project.ssh_urls()),
459            SerializedWorkspaceLocation::DevServer(dev_server_project) => {
460                Arc::new(vec![PathBuf::from(format!(
461                    "{}:{}",
462                    dev_server_project.dev_server_name,
463                    dev_server_project.paths.join(", ")
464                ))])
465            }
466        };
467
468        let (match_labels, paths): (Vec<_>, Vec<_>) = paths
469            .iter()
470            .map(|path| {
471                let highlighted_text =
472                    highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
473
474                path_start_offset += highlighted_text.1.char_count;
475                highlighted_text
476            })
477            .unzip();
478
479        let highlighted_match = HighlightedMatchWithPaths {
480            match_label: HighlightedText::join(match_labels.into_iter().flatten(), ", ").color(
481                if matches!(dev_server_status, Some(DevServerStatus::Offline)) {
482                    Color::Disabled
483                } else {
484                    Color::Default
485                },
486            ),
487            paths,
488        };
489
490        Some(
491            ListItem::new(ix)
492                .selected(selected)
493                .inset(true)
494                .spacing(ListItemSpacing::Sparse)
495                .child(
496                    h_flex()
497                        .flex_grow()
498                        .gap_3()
499                        .when(self.has_any_non_local_projects, |this| {
500                            this.child(match location {
501                                SerializedWorkspaceLocation::Local(_, _) => {
502                                    Icon::new(IconName::Screen)
503                                        .color(Color::Muted)
504                                        .into_any_element()
505                                }
506                                SerializedWorkspaceLocation::Ssh(_) => Icon::new(IconName::Server)
507                                    .color(Color::Muted)
508                                    .into_any_element(),
509                                SerializedWorkspaceLocation::DevServer(_) => {
510                                    let indicator_color = match dev_server_status {
511                                        Some(DevServerStatus::Online) => Color::Created,
512                                        Some(DevServerStatus::Offline) => Color::Hidden,
513                                        _ => unreachable!(),
514                                    };
515                                    IconWithIndicator::new(
516                                        Icon::new(IconName::Server).color(Color::Muted),
517                                        Some(Indicator::dot()),
518                                    )
519                                    .indicator_color(indicator_color)
520                                    .indicator_border_color(if selected {
521                                        Some(cx.theme().colors().element_selected)
522                                    } else {
523                                        None
524                                    })
525                                    .into_any_element()
526                                }
527                            })
528                        })
529                        .child({
530                            let mut highlighted = highlighted_match.clone();
531                            if !self.render_paths {
532                                highlighted.paths.clear();
533                            }
534                            highlighted.render(cx)
535                        }),
536                )
537                .map(|el| {
538                    let delete_button = div()
539                        .child(
540                            IconButton::new("delete", IconName::Close)
541                                .icon_size(IconSize::Small)
542                                .on_click(cx.listener(move |this, _event, cx| {
543                                    cx.stop_propagation();
544                                    cx.prevent_default();
545
546                                    this.delegate.delete_recent_project(ix, cx)
547                                }))
548                                .tooltip(|cx| Tooltip::text("Delete from Recent Projects...", cx)),
549                        )
550                        .into_any_element();
551
552                    if self.selected_index() == ix {
553                        el.end_slot::<AnyElement>(delete_button)
554                    } else {
555                        el.end_hover_slot::<AnyElement>(delete_button)
556                    }
557                })
558                .tooltip(move |cx| {
559                    let tooltip_highlighted_location = highlighted_match.clone();
560                    cx.new_view(move |_| MatchTooltip {
561                        highlighted_location: tooltip_highlighted_location,
562                    })
563                    .into()
564                }),
565        )
566    }
567
568    fn render_footer(&self, cx: &mut ViewContext<Picker<Self>>) -> Option<AnyElement> {
569        Some(
570            h_flex()
571                .border_t_1()
572                .py_2()
573                .pr_2()
574                .border_color(cx.theme().colors().border_variant)
575                .justify_end()
576                .gap_4()
577                .child(
578                    ButtonLike::new("remote")
579                        .when_some(KeyBinding::for_action(&OpenRemote, cx), |button, key| {
580                            button.child(key)
581                        })
582                        .child(Label::new("Open Remote Folder…").color(Color::Muted))
583                        .on_click(|_, cx| cx.dispatch_action(OpenRemote.boxed_clone())),
584                )
585                .child(
586                    ButtonLike::new("local")
587                        .when_some(
588                            KeyBinding::for_action(&workspace::Open, cx),
589                            |button, key| button.child(key),
590                        )
591                        .child(Label::new("Open Local Folder…").color(Color::Muted))
592                        .on_click(|_, cx| cx.dispatch_action(workspace::Open.boxed_clone())),
593                )
594                .into_any(),
595        )
596    }
597}
598
599fn open_dev_server_project(
600    replace_current_window: bool,
601    dev_server_project_id: DevServerProjectId,
602    project_id: ProjectId,
603    cx: &mut ViewContext<Workspace>,
604) -> Task<anyhow::Result<()>> {
605    if let Some(app_state) = AppState::global(cx).upgrade() {
606        let handle = if replace_current_window {
607            cx.window_handle().downcast::<Workspace>()
608        } else {
609            None
610        };
611
612        if let Some(handle) = handle {
613            cx.spawn(move |workspace, mut cx| async move {
614                let continue_replacing = workspace
615                    .update(&mut cx, |workspace, cx| {
616                        workspace.prepare_to_close(CloseIntent::ReplaceWindow, cx)
617                    })?
618                    .await?;
619                if continue_replacing {
620                    workspace
621                        .update(&mut cx, |_workspace, cx| {
622                            workspace::join_dev_server_project(
623                                dev_server_project_id,
624                                project_id,
625                                app_state,
626                                Some(handle),
627                                cx,
628                            )
629                        })?
630                        .await?;
631                }
632                Ok(())
633            })
634        } else {
635            let task = workspace::join_dev_server_project(
636                dev_server_project_id,
637                project_id,
638                app_state,
639                None,
640                cx,
641            );
642            cx.spawn(|_, _| async move {
643                task.await?;
644                Ok(())
645            })
646        }
647    } else {
648        Task::ready(Err(anyhow::anyhow!("App state not found")))
649    }
650}
651
652// Compute the highlighted text for the name and path
653fn highlights_for_path(
654    path: &Path,
655    match_positions: &Vec<usize>,
656    path_start_offset: usize,
657) -> (Option<HighlightedText>, HighlightedText) {
658    let path_string = path.to_string_lossy();
659    let path_char_count = path_string.chars().count();
660    // Get the subset of match highlight positions that line up with the given path.
661    // Also adjusts them to start at the path start
662    let path_positions = match_positions
663        .iter()
664        .copied()
665        .skip_while(|position| *position < path_start_offset)
666        .take_while(|position| *position < path_start_offset + path_char_count)
667        .map(|position| position - path_start_offset)
668        .collect::<Vec<_>>();
669
670    // Again subset the highlight positions to just those that line up with the file_name
671    // again adjusted to the start of the file_name
672    let file_name_text_and_positions = path.file_name().map(|file_name| {
673        let text = file_name.to_string_lossy();
674        let char_count = text.chars().count();
675        let file_name_start = path_char_count - char_count;
676        let highlight_positions = path_positions
677            .iter()
678            .copied()
679            .skip_while(|position| *position < file_name_start)
680            .take_while(|position| *position < file_name_start + char_count)
681            .map(|position| position - file_name_start)
682            .collect::<Vec<_>>();
683        HighlightedText {
684            text: text.to_string(),
685            highlight_positions,
686            char_count,
687            color: Color::Default,
688        }
689    });
690
691    (
692        file_name_text_and_positions,
693        HighlightedText {
694            text: path_string.to_string(),
695            highlight_positions: path_positions,
696            char_count: path_char_count,
697            color: Color::Default,
698        },
699    )
700}
701impl RecentProjectsDelegate {
702    fn delete_recent_project(&self, ix: usize, cx: &mut ViewContext<Picker<Self>>) {
703        if let Some(selected_match) = self.matches.get(ix) {
704            let (workspace_id, _) = self.workspaces[selected_match.candidate_id];
705            cx.spawn(move |this, mut cx| async move {
706                let _ = WORKSPACE_DB.delete_workspace_by_id(workspace_id).await;
707                let workspaces = WORKSPACE_DB
708                    .recent_workspaces_on_disk()
709                    .await
710                    .unwrap_or_default();
711                this.update(&mut cx, move |picker, cx| {
712                    picker.delegate.set_workspaces(workspaces);
713                    picker.delegate.set_selected_index(ix.saturating_sub(1), cx);
714                    picker.delegate.reset_selected_match_index = false;
715                    picker.update_matches(picker.query(cx), cx)
716                })
717            })
718            .detach();
719        }
720    }
721
722    fn is_current_workspace(
723        &self,
724        workspace_id: WorkspaceId,
725        cx: &mut ViewContext<Picker<Self>>,
726    ) -> bool {
727        if let Some(workspace) = self.workspace.upgrade() {
728            let workspace = workspace.read(cx);
729            if Some(workspace_id) == workspace.database_id() {
730                return true;
731            }
732        }
733
734        false
735    }
736}
737struct MatchTooltip {
738    highlighted_location: HighlightedMatchWithPaths,
739}
740
741impl Render for MatchTooltip {
742    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
743        tooltip_container(cx, |div, _| {
744            self.highlighted_location.render_paths_children(div)
745        })
746    }
747}
748
749#[cfg(test)]
750mod tests {
751    use std::path::PathBuf;
752
753    use editor::Editor;
754    use gpui::{TestAppContext, UpdateGlobal, WindowHandle};
755    use project::{project_settings::ProjectSettings, Project};
756    use serde_json::json;
757    use settings::SettingsStore;
758    use workspace::{open_paths, AppState};
759
760    use super::*;
761
762    #[gpui::test]
763    async fn test_prompts_on_dirty_before_submit(cx: &mut TestAppContext) {
764        let app_state = init_test(cx);
765
766        cx.update(|cx| {
767            SettingsStore::update_global(cx, |store, cx| {
768                store.update_user_settings::<ProjectSettings>(cx, |settings| {
769                    settings.session.restore_unsaved_buffers = false
770                });
771            });
772        });
773
774        app_state
775            .fs
776            .as_fake()
777            .insert_tree(
778                "/dir",
779                json!({
780                    "main.ts": "a"
781                }),
782            )
783            .await;
784        cx.update(|cx| {
785            open_paths(
786                &[PathBuf::from("/dir/main.ts")],
787                app_state,
788                workspace::OpenOptions::default(),
789                cx,
790            )
791        })
792        .await
793        .unwrap();
794        assert_eq!(cx.update(|cx| cx.windows().len()), 1);
795
796        let workspace = cx.update(|cx| cx.windows()[0].downcast::<Workspace>().unwrap());
797        workspace
798            .update(cx, |workspace, _| assert!(!workspace.is_edited()))
799            .unwrap();
800
801        let editor = workspace
802            .read_with(cx, |workspace, cx| {
803                workspace
804                    .active_item(cx)
805                    .unwrap()
806                    .downcast::<Editor>()
807                    .unwrap()
808            })
809            .unwrap();
810        workspace
811            .update(cx, |_, cx| {
812                editor.update(cx, |editor, cx| editor.insert("EDIT", cx));
813            })
814            .unwrap();
815        workspace
816            .update(cx, |workspace, _| assert!(workspace.is_edited(), "After inserting more text into the editor without saving, we should have a dirty project"))
817            .unwrap();
818
819        let recent_projects_picker = open_recent_projects(&workspace, cx);
820        workspace
821            .update(cx, |_, cx| {
822                recent_projects_picker.update(cx, |picker, cx| {
823                    assert_eq!(picker.query(cx), "");
824                    let delegate = &mut picker.delegate;
825                    delegate.matches = vec![StringMatch {
826                        candidate_id: 0,
827                        score: 1.0,
828                        positions: Vec::new(),
829                        string: "fake candidate".to_string(),
830                    }];
831                    delegate.set_workspaces(vec![(
832                        WorkspaceId::default(),
833                        SerializedWorkspaceLocation::from_local_paths(vec!["/test/path/"]),
834                    )]);
835                });
836            })
837            .unwrap();
838
839        assert!(
840            !cx.has_pending_prompt(),
841            "Should have no pending prompt on dirty project before opening the new recent project"
842        );
843        cx.dispatch_action(*workspace, menu::Confirm);
844        workspace
845            .update(cx, |workspace, cx| {
846                assert!(
847                    workspace.active_modal::<RecentProjects>(cx).is_none(),
848                    "Should remove the modal after selecting new recent project"
849                )
850            })
851            .unwrap();
852        assert!(
853            cx.has_pending_prompt(),
854            "Dirty workspace should prompt before opening the new recent project"
855        );
856        // Cancel
857        cx.simulate_prompt_answer(0);
858        assert!(
859            !cx.has_pending_prompt(),
860            "Should have no pending prompt after cancelling"
861        );
862        workspace
863            .update(cx, |workspace, _| {
864                assert!(
865                    workspace.is_edited(),
866                    "Should be in the same dirty project after cancelling"
867                )
868            })
869            .unwrap();
870    }
871
872    fn open_recent_projects(
873        workspace: &WindowHandle<Workspace>,
874        cx: &mut TestAppContext,
875    ) -> View<Picker<RecentProjectsDelegate>> {
876        cx.dispatch_action(
877            (*workspace).into(),
878            OpenRecent {
879                create_new_window: false,
880            },
881        );
882        workspace
883            .update(cx, |workspace, cx| {
884                workspace
885                    .active_modal::<RecentProjects>(cx)
886                    .unwrap()
887                    .read(cx)
888                    .picker
889                    .clone()
890            })
891            .unwrap()
892    }
893
894    fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
895        cx.update(|cx| {
896            let state = AppState::test(cx);
897            language::init(cx);
898            crate::init(cx);
899            editor::init(cx);
900            workspace::init_settings(cx);
901            Project::init_settings(cx);
902            state
903        })
904    }
905}