recent_projects.rs

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