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 nickname = SshSettings::get_global(cx).nickname_for(&ssh_project.host, ssh_project.port, &ssh_project.user);
392                            let connection_options = SshConnectionOptions {
393                                host: ssh_project.host.clone(),
394                                username: ssh_project.user.clone(),
395                                port: ssh_project.port,
396                                password: None,
397                                args,
398                            };
399
400                            let paths = ssh_project.paths.iter().map(PathBuf::from).collect();
401
402                            cx.spawn(|_, mut cx| async move {
403                                open_ssh_project(connection_options, paths, app_state, open_options, nickname, &mut cx).await
404                            })
405                        }
406                    }
407                }
408                })
409            .detach_and_log_err(cx);
410            cx.emit(DismissEvent);
411        }
412    }
413
414    fn dismissed(&mut self, _: &mut ViewContext<Picker<Self>>) {}
415
416    fn no_matches_text(&self, _cx: &mut WindowContext) -> SharedString {
417        if self.workspaces.is_empty() {
418            "Recently opened projects will show up here".into()
419        } else {
420            "No matches".into()
421        }
422    }
423
424    fn render_match(
425        &self,
426        ix: usize,
427        selected: bool,
428        cx: &mut ViewContext<Picker<Self>>,
429    ) -> Option<Self::ListItem> {
430        let hit = self.matches.get(ix)?;
431
432        let (_, location) = self.workspaces.get(hit.candidate_id)?;
433
434        let dev_server_status =
435            if let SerializedWorkspaceLocation::DevServer(dev_server_project) = location {
436                let store = dev_server_projects::Store::global(cx).read(cx);
437                Some(
438                    store
439                        .dev_server_project(dev_server_project.id)
440                        .and_then(|p| store.dev_server(p.dev_server_id))
441                        .map(|s| s.status)
442                        .unwrap_or_default(),
443                )
444            } else {
445                None
446            };
447
448        let mut path_start_offset = 0;
449        let paths = match location {
450            SerializedWorkspaceLocation::Local(paths, order) => Arc::new(
451                order
452                    .order()
453                    .iter()
454                    .zip(paths.paths().iter())
455                    .sorted_by_key(|(i, _)| **i)
456                    .map(|(_, path)| path.compact())
457                    .collect(),
458            ),
459            SerializedWorkspaceLocation::Ssh(ssh_project) => Arc::new(ssh_project.ssh_urls()),
460            SerializedWorkspaceLocation::DevServer(dev_server_project) => {
461                Arc::new(vec![PathBuf::from(format!(
462                    "{}:{}",
463                    dev_server_project.dev_server_name,
464                    dev_server_project.paths.join(", ")
465                ))])
466            }
467        };
468
469        let (match_labels, paths): (Vec<_>, Vec<_>) = paths
470            .iter()
471            .map(|path| {
472                let highlighted_text =
473                    highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
474
475                path_start_offset += highlighted_text.1.char_count;
476                highlighted_text
477            })
478            .unzip();
479
480        let highlighted_match = HighlightedMatchWithPaths {
481            match_label: HighlightedText::join(match_labels.into_iter().flatten(), ", ").color(
482                if matches!(dev_server_status, Some(DevServerStatus::Offline)) {
483                    Color::Disabled
484                } else {
485                    Color::Default
486                },
487            ),
488            paths,
489        };
490
491        Some(
492            ListItem::new(ix)
493                .selected(selected)
494                .inset(true)
495                .spacing(ListItemSpacing::Sparse)
496                .child(
497                    h_flex()
498                        .flex_grow()
499                        .gap_3()
500                        .when(self.has_any_non_local_projects, |this| {
501                            this.child(match location {
502                                SerializedWorkspaceLocation::Local(_, _) => {
503                                    Icon::new(IconName::Screen)
504                                        .color(Color::Muted)
505                                        .into_any_element()
506                                }
507                                SerializedWorkspaceLocation::Ssh(_) => Icon::new(IconName::Server)
508                                    .color(Color::Muted)
509                                    .into_any_element(),
510                                SerializedWorkspaceLocation::DevServer(_) => {
511                                    let indicator_color = match dev_server_status {
512                                        Some(DevServerStatus::Online) => Color::Created,
513                                        Some(DevServerStatus::Offline) => Color::Hidden,
514                                        _ => unreachable!(),
515                                    };
516                                    IconWithIndicator::new(
517                                        Icon::new(IconName::Server).color(Color::Muted),
518                                        Some(Indicator::dot()),
519                                    )
520                                    .indicator_color(indicator_color)
521                                    .indicator_border_color(if selected {
522                                        Some(cx.theme().colors().element_selected)
523                                    } else {
524                                        None
525                                    })
526                                    .into_any_element()
527                                }
528                            })
529                        })
530                        .child({
531                            let mut highlighted = highlighted_match.clone();
532                            if !self.render_paths {
533                                highlighted.paths.clear();
534                            }
535                            highlighted.render(cx)
536                        }),
537                )
538                .map(|el| {
539                    let delete_button = div()
540                        .child(
541                            IconButton::new("delete", IconName::Close)
542                                .icon_size(IconSize::Small)
543                                .on_click(cx.listener(move |this, _event, cx| {
544                                    cx.stop_propagation();
545                                    cx.prevent_default();
546
547                                    this.delegate.delete_recent_project(ix, cx)
548                                }))
549                                .tooltip(|cx| Tooltip::text("Delete from Recent Projects...", cx)),
550                        )
551                        .into_any_element();
552
553                    if self.selected_index() == ix {
554                        el.end_slot::<AnyElement>(delete_button)
555                    } else {
556                        el.end_hover_slot::<AnyElement>(delete_button)
557                    }
558                })
559                .tooltip(move |cx| {
560                    let tooltip_highlighted_location = highlighted_match.clone();
561                    cx.new_view(move |_| MatchTooltip {
562                        highlighted_location: tooltip_highlighted_location,
563                    })
564                    .into()
565                }),
566        )
567    }
568
569    fn render_footer(&self, cx: &mut ViewContext<Picker<Self>>) -> Option<AnyElement> {
570        Some(
571            h_flex()
572                .border_t_1()
573                .py_2()
574                .pr_2()
575                .border_color(cx.theme().colors().border_variant)
576                .justify_end()
577                .gap_4()
578                .child(
579                    ButtonLike::new("remote")
580                        .when_some(KeyBinding::for_action(&OpenRemote, cx), |button, key| {
581                            button.child(key)
582                        })
583                        .child(Label::new("Open Remote Folder…").color(Color::Muted))
584                        .on_click(|_, cx| cx.dispatch_action(OpenRemote.boxed_clone())),
585                )
586                .child(
587                    ButtonLike::new("local")
588                        .when_some(
589                            KeyBinding::for_action(&workspace::Open, cx),
590                            |button, key| button.child(key),
591                        )
592                        .child(Label::new("Open Local Folder…").color(Color::Muted))
593                        .on_click(|_, cx| cx.dispatch_action(workspace::Open.boxed_clone())),
594                )
595                .into_any(),
596        )
597    }
598}
599
600fn open_dev_server_project(
601    replace_current_window: bool,
602    dev_server_project_id: DevServerProjectId,
603    project_id: ProjectId,
604    cx: &mut ViewContext<Workspace>,
605) -> Task<anyhow::Result<()>> {
606    if let Some(app_state) = AppState::global(cx).upgrade() {
607        let handle = if replace_current_window {
608            cx.window_handle().downcast::<Workspace>()
609        } else {
610            None
611        };
612
613        if let Some(handle) = handle {
614            cx.spawn(move |workspace, mut cx| async move {
615                let continue_replacing = workspace
616                    .update(&mut cx, |workspace, cx| {
617                        workspace.prepare_to_close(CloseIntent::ReplaceWindow, cx)
618                    })?
619                    .await?;
620                if continue_replacing {
621                    workspace
622                        .update(&mut cx, |_workspace, cx| {
623                            workspace::join_dev_server_project(
624                                dev_server_project_id,
625                                project_id,
626                                app_state,
627                                Some(handle),
628                                cx,
629                            )
630                        })?
631                        .await?;
632                }
633                Ok(())
634            })
635        } else {
636            let task = workspace::join_dev_server_project(
637                dev_server_project_id,
638                project_id,
639                app_state,
640                None,
641                cx,
642            );
643            cx.spawn(|_, _| async move {
644                task.await?;
645                Ok(())
646            })
647        }
648    } else {
649        Task::ready(Err(anyhow::anyhow!("App state not found")))
650    }
651}
652
653// Compute the highlighted text for the name and path
654fn highlights_for_path(
655    path: &Path,
656    match_positions: &Vec<usize>,
657    path_start_offset: usize,
658) -> (Option<HighlightedText>, HighlightedText) {
659    let path_string = path.to_string_lossy();
660    let path_char_count = path_string.chars().count();
661    // Get the subset of match highlight positions that line up with the given path.
662    // Also adjusts them to start at the path start
663    let path_positions = match_positions
664        .iter()
665        .copied()
666        .skip_while(|position| *position < path_start_offset)
667        .take_while(|position| *position < path_start_offset + path_char_count)
668        .map(|position| position - path_start_offset)
669        .collect::<Vec<_>>();
670
671    // Again subset the highlight positions to just those that line up with the file_name
672    // again adjusted to the start of the file_name
673    let file_name_text_and_positions = path.file_name().map(|file_name| {
674        let text = file_name.to_string_lossy();
675        let char_count = text.chars().count();
676        let file_name_start = path_char_count - char_count;
677        let highlight_positions = path_positions
678            .iter()
679            .copied()
680            .skip_while(|position| *position < file_name_start)
681            .take_while(|position| *position < file_name_start + char_count)
682            .map(|position| position - file_name_start)
683            .collect::<Vec<_>>();
684        HighlightedText {
685            text: text.to_string(),
686            highlight_positions,
687            char_count,
688            color: Color::Default,
689        }
690    });
691
692    (
693        file_name_text_and_positions,
694        HighlightedText {
695            text: path_string.to_string(),
696            highlight_positions: path_positions,
697            char_count: path_char_count,
698            color: Color::Default,
699        },
700    )
701}
702impl RecentProjectsDelegate {
703    fn delete_recent_project(&self, ix: usize, cx: &mut ViewContext<Picker<Self>>) {
704        if let Some(selected_match) = self.matches.get(ix) {
705            let (workspace_id, _) = self.workspaces[selected_match.candidate_id];
706            cx.spawn(move |this, mut cx| async move {
707                let _ = WORKSPACE_DB.delete_workspace_by_id(workspace_id).await;
708                let workspaces = WORKSPACE_DB
709                    .recent_workspaces_on_disk()
710                    .await
711                    .unwrap_or_default();
712                this.update(&mut cx, move |picker, cx| {
713                    picker.delegate.set_workspaces(workspaces);
714                    picker.delegate.set_selected_index(ix.saturating_sub(1), cx);
715                    picker.delegate.reset_selected_match_index = false;
716                    picker.update_matches(picker.query(cx), cx)
717                })
718            })
719            .detach();
720        }
721    }
722
723    fn is_current_workspace(
724        &self,
725        workspace_id: WorkspaceId,
726        cx: &mut ViewContext<Picker<Self>>,
727    ) -> bool {
728        if let Some(workspace) = self.workspace.upgrade() {
729            let workspace = workspace.read(cx);
730            if Some(workspace_id) == workspace.database_id() {
731                return true;
732            }
733        }
734
735        false
736    }
737}
738struct MatchTooltip {
739    highlighted_location: HighlightedMatchWithPaths,
740}
741
742impl Render for MatchTooltip {
743    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
744        tooltip_container(cx, |div, _| {
745            self.highlighted_location.render_paths_children(div)
746        })
747    }
748}
749
750#[cfg(test)]
751mod tests {
752    use std::path::PathBuf;
753
754    use editor::Editor;
755    use gpui::{TestAppContext, UpdateGlobal, WindowHandle};
756    use project::{project_settings::ProjectSettings, Project};
757    use serde_json::json;
758    use settings::SettingsStore;
759    use workspace::{open_paths, AppState};
760
761    use super::*;
762
763    #[gpui::test]
764    async fn test_prompts_on_dirty_before_submit(cx: &mut TestAppContext) {
765        let app_state = init_test(cx);
766
767        cx.update(|cx| {
768            SettingsStore::update_global(cx, |store, cx| {
769                store.update_user_settings::<ProjectSettings>(cx, |settings| {
770                    settings.session.restore_unsaved_buffers = false
771                });
772            });
773        });
774
775        app_state
776            .fs
777            .as_fake()
778            .insert_tree(
779                "/dir",
780                json!({
781                    "main.ts": "a"
782                }),
783            )
784            .await;
785        cx.update(|cx| {
786            open_paths(
787                &[PathBuf::from("/dir/main.ts")],
788                app_state,
789                workspace::OpenOptions::default(),
790                cx,
791            )
792        })
793        .await
794        .unwrap();
795        assert_eq!(cx.update(|cx| cx.windows().len()), 1);
796
797        let workspace = cx.update(|cx| cx.windows()[0].downcast::<Workspace>().unwrap());
798        workspace
799            .update(cx, |workspace, _| assert!(!workspace.is_edited()))
800            .unwrap();
801
802        let editor = workspace
803            .read_with(cx, |workspace, cx| {
804                workspace
805                    .active_item(cx)
806                    .unwrap()
807                    .downcast::<Editor>()
808                    .unwrap()
809            })
810            .unwrap();
811        workspace
812            .update(cx, |_, cx| {
813                editor.update(cx, |editor, cx| editor.insert("EDIT", cx));
814            })
815            .unwrap();
816        workspace
817            .update(cx, |workspace, _| assert!(workspace.is_edited(), "After inserting more text into the editor without saving, we should have a dirty project"))
818            .unwrap();
819
820        let recent_projects_picker = open_recent_projects(&workspace, cx);
821        workspace
822            .update(cx, |_, cx| {
823                recent_projects_picker.update(cx, |picker, cx| {
824                    assert_eq!(picker.query(cx), "");
825                    let delegate = &mut picker.delegate;
826                    delegate.matches = vec![StringMatch {
827                        candidate_id: 0,
828                        score: 1.0,
829                        positions: Vec::new(),
830                        string: "fake candidate".to_string(),
831                    }];
832                    delegate.set_workspaces(vec![(
833                        WorkspaceId::default(),
834                        SerializedWorkspaceLocation::from_local_paths(vec!["/test/path/"]),
835                    )]);
836                });
837            })
838            .unwrap();
839
840        assert!(
841            !cx.has_pending_prompt(),
842            "Should have no pending prompt on dirty project before opening the new recent project"
843        );
844        cx.dispatch_action(*workspace, menu::Confirm);
845        workspace
846            .update(cx, |workspace, cx| {
847                assert!(
848                    workspace.active_modal::<RecentProjects>(cx).is_none(),
849                    "Should remove the modal after selecting new recent project"
850                )
851            })
852            .unwrap();
853        assert!(
854            cx.has_pending_prompt(),
855            "Dirty workspace should prompt before opening the new recent project"
856        );
857        // Cancel
858        cx.simulate_prompt_answer(0);
859        assert!(
860            !cx.has_pending_prompt(),
861            "Should have no pending prompt after cancelling"
862        );
863        workspace
864            .update(cx, |workspace, _| {
865                assert!(
866                    workspace.is_edited(),
867                    "Should be in the same dirty project after cancelling"
868                )
869            })
870            .unwrap();
871    }
872
873    fn open_recent_projects(
874        workspace: &WindowHandle<Workspace>,
875        cx: &mut TestAppContext,
876    ) -> View<Picker<RecentProjectsDelegate>> {
877        cx.dispatch_action(
878            (*workspace).into(),
879            OpenRecent {
880                create_new_window: false,
881            },
882        );
883        workspace
884            .update(cx, |workspace, cx| {
885                workspace
886                    .active_modal::<RecentProjects>(cx)
887                    .unwrap()
888                    .read(cx)
889                    .picker
890                    .clone()
891            })
892            .unwrap()
893    }
894
895    fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
896        cx.update(|cx| {
897            let state = AppState::test(cx);
898            language::init(cx);
899            crate::init(cx);
900            editor::init(cx);
901            workspace::init_settings(cx);
902            Project::init_settings(cx);
903            state
904        })
905    }
906}