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