welcome.rs

  1use crate::{
  2    NewFile, Open, PathList, SerializedWorkspaceLocation, WORKSPACE_DB, Workspace, WorkspaceId,
  3    item::{Item, ItemEvent},
  4};
  5use chrono::{DateTime, Utc};
  6use git::Clone as GitClone;
  7use gpui::WeakEntity;
  8use gpui::{
  9    Action, App, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
 10    ParentElement, Render, Styled, Task, Window, actions,
 11};
 12use menu::{SelectNext, SelectPrevious};
 13use project::DisableAiSettings;
 14use schemars::JsonSchema;
 15use serde::{Deserialize, Serialize};
 16use settings::Settings;
 17use ui::{ButtonLike, Divider, DividerColor, KeyBinding, Vector, VectorName, prelude::*};
 18use util::ResultExt;
 19use zed_actions::{Extensions, OpenOnboarding, OpenSettings, agent, command_palette};
 20
 21#[derive(PartialEq, Clone, Debug, Deserialize, Serialize, JsonSchema, Action)]
 22#[action(namespace = welcome)]
 23#[serde(transparent)]
 24pub struct OpenRecentProject {
 25    pub index: usize,
 26}
 27
 28actions!(
 29    zed,
 30    [
 31        /// Show the Zed welcome screen
 32        ShowWelcome
 33    ]
 34);
 35
 36#[derive(IntoElement)]
 37struct SectionHeader {
 38    title: SharedString,
 39}
 40
 41impl SectionHeader {
 42    fn new(title: impl Into<SharedString>) -> Self {
 43        Self {
 44            title: title.into(),
 45        }
 46    }
 47}
 48
 49impl RenderOnce for SectionHeader {
 50    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
 51        h_flex()
 52            .px_1()
 53            .mb_2()
 54            .gap_2()
 55            .child(
 56                Label::new(self.title.to_ascii_uppercase())
 57                    .buffer_font(cx)
 58                    .color(Color::Muted)
 59                    .size(LabelSize::XSmall),
 60            )
 61            .child(Divider::horizontal().color(DividerColor::BorderVariant))
 62    }
 63}
 64
 65#[derive(IntoElement)]
 66struct SectionButton {
 67    label: SharedString,
 68    icon: IconName,
 69    action: Box<dyn Action>,
 70    tab_index: usize,
 71    focus_handle: FocusHandle,
 72}
 73
 74impl SectionButton {
 75    fn new(
 76        label: impl Into<SharedString>,
 77        icon: IconName,
 78        action: &dyn Action,
 79        tab_index: usize,
 80        focus_handle: FocusHandle,
 81    ) -> Self {
 82        Self {
 83            label: label.into(),
 84            icon,
 85            action: action.boxed_clone(),
 86            tab_index,
 87            focus_handle,
 88        }
 89    }
 90}
 91
 92impl RenderOnce for SectionButton {
 93    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
 94        let id = format!("onb-button-{}-{}", self.label, self.tab_index);
 95        let action_ref: &dyn Action = &*self.action;
 96
 97        ButtonLike::new(id)
 98            .tab_index(self.tab_index as isize)
 99            .full_width()
100            .size(ButtonSize::Medium)
101            .child(
102                h_flex()
103                    .w_full()
104                    .justify_between()
105                    .child(
106                        div().id("test-a11y-id").role(gpui::Role::Button).child("hello")
107                    )
108                    .child(
109                        h_flex()
110                            .gap_2()
111                            .child(
112                                Icon::new(self.icon)
113                                    .color(Color::Muted)
114                                    .size(IconSize::Small),
115                            )
116                            .child(Label::new(self.label)),
117                    )
118                    .child(
119                        KeyBinding::for_action_in(action_ref, &self.focus_handle, cx)
120                            .size(rems_from_px(12.)),
121                    ),
122            )
123            .on_click(move |_, window, cx| {
124                self.focus_handle.dispatch_action(&*self.action, window, cx)
125            })
126    }
127}
128
129enum SectionVisibility {
130    Always,
131    Conditional(fn(&App) -> bool),
132}
133
134impl SectionVisibility {
135    fn is_visible(&self, cx: &App) -> bool {
136        match self {
137            SectionVisibility::Always => true,
138            SectionVisibility::Conditional(f) => f(cx),
139        }
140    }
141}
142
143struct SectionEntry {
144    icon: IconName,
145    title: &'static str,
146    action: &'static dyn Action,
147    visibility_guard: SectionVisibility,
148}
149
150impl SectionEntry {
151    fn render(
152        &self,
153        button_index: usize,
154        focus: &FocusHandle,
155        cx: &App,
156    ) -> Option<impl IntoElement> {
157        self.visibility_guard.is_visible(cx).then(|| {
158            SectionButton::new(
159                self.title,
160                self.icon,
161                self.action,
162                button_index,
163                focus.clone(),
164            )
165        })
166    }
167}
168
169const CONTENT: (Section<4>, Section<3>) = (
170    Section {
171        title: "Get Started",
172        entries: [
173            SectionEntry {
174                icon: IconName::Plus,
175                title: "New File",
176                action: &NewFile,
177                visibility_guard: SectionVisibility::Always,
178            },
179            SectionEntry {
180                icon: IconName::FolderOpen,
181                title: "Open Project",
182                action: &Open::DEFAULT,
183                visibility_guard: SectionVisibility::Always,
184            },
185            SectionEntry {
186                icon: IconName::CloudDownload,
187                title: "Clone Repository",
188                action: &GitClone,
189                visibility_guard: SectionVisibility::Always,
190            },
191            SectionEntry {
192                icon: IconName::ListCollapse,
193                title: "Open Command Palette",
194                action: &command_palette::Toggle,
195                visibility_guard: SectionVisibility::Always,
196            },
197        ],
198    },
199    Section {
200        title: "Configure",
201        entries: [
202            SectionEntry {
203                icon: IconName::Settings,
204                title: "Open Settings",
205                action: &OpenSettings,
206                visibility_guard: SectionVisibility::Always,
207            },
208            SectionEntry {
209                icon: IconName::ZedAssistant,
210                title: "View AI Settings",
211                action: &agent::OpenSettings,
212                visibility_guard: SectionVisibility::Conditional(|cx| {
213                    !DisableAiSettings::get_global(cx).disable_ai
214                }),
215            },
216            SectionEntry {
217                icon: IconName::Blocks,
218                title: "Explore Extensions",
219                action: &Extensions {
220                    category_filter: None,
221                    id: None,
222                },
223                visibility_guard: SectionVisibility::Always,
224            },
225        ],
226    },
227);
228
229struct Section<const COLS: usize> {
230    title: &'static str,
231    entries: [SectionEntry; COLS],
232}
233
234impl<const COLS: usize> Section<COLS> {
235    fn render(self, index_offset: usize, focus: &FocusHandle, cx: &App) -> impl IntoElement {
236        v_flex()
237            .min_w_full()
238            .child(SectionHeader::new(self.title))
239            .children(
240                self.entries
241                    .iter()
242                    .enumerate()
243                    .filter_map(|(index, entry)| entry.render(index_offset + index, focus, cx)),
244            )
245    }
246}
247
248pub struct WelcomePage {
249    workspace: WeakEntity<Workspace>,
250    focus_handle: FocusHandle,
251    fallback_to_recent_projects: bool,
252    recent_workspaces: Option<
253        Vec<(
254            WorkspaceId,
255            SerializedWorkspaceLocation,
256            PathList,
257            DateTime<Utc>,
258        )>,
259    >,
260}
261
262impl WelcomePage {
263    pub fn new(
264        workspace: WeakEntity<Workspace>,
265        fallback_to_recent_projects: bool,
266        window: &mut Window,
267        cx: &mut Context<Self>,
268    ) -> Self {
269        let focus_handle = cx.focus_handle();
270        cx.on_focus(&focus_handle, window, |_, _, cx| cx.notify())
271            .detach();
272
273        if fallback_to_recent_projects {
274            let fs = workspace
275                .upgrade()
276                .map(|ws| ws.read(cx).app_state().fs.clone());
277            cx.spawn_in(window, async move |this: WeakEntity<Self>, cx| {
278                let Some(fs) = fs else { return };
279                let workspaces = WORKSPACE_DB
280                    .recent_workspaces_on_disk(fs.as_ref())
281                    .await
282                    .log_err()
283                    .unwrap_or_default();
284
285                this.update(cx, |this, cx| {
286                    this.recent_workspaces = Some(workspaces);
287                    cx.notify();
288                })
289                .ok();
290            })
291            .detach();
292        }
293
294        WelcomePage {
295            workspace,
296            focus_handle,
297            fallback_to_recent_projects,
298            recent_workspaces: None,
299        }
300    }
301
302    fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
303        window.focus_next(cx);
304        cx.notify();
305    }
306
307    fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
308        window.focus_prev(cx);
309        cx.notify();
310    }
311
312    fn open_recent_project(
313        &mut self,
314        action: &OpenRecentProject,
315        window: &mut Window,
316        cx: &mut Context<Self>,
317    ) {
318        if let Some(recent_workspaces) = &self.recent_workspaces {
319            if let Some((_workspace_id, location, paths, _timestamp)) =
320                recent_workspaces.get(action.index)
321            {
322                let is_local = matches!(location, SerializedWorkspaceLocation::Local);
323
324                if is_local {
325                    let paths = paths.clone();
326                    let paths = paths.paths().to_vec();
327                    self.workspace
328                        .update(cx, |workspace, cx| {
329                            workspace
330                                .open_workspace_for_paths(true, paths, window, cx)
331                                .detach_and_log_err(cx);
332                        })
333                        .log_err();
334                } else {
335                    use zed_actions::OpenRecent;
336                    window.dispatch_action(OpenRecent::default().boxed_clone(), cx);
337                }
338            }
339        }
340    }
341
342    fn render_recent_project_section(
343        &self,
344        recent_projects: Vec<impl IntoElement>,
345    ) -> impl IntoElement {
346        v_flex()
347            .w_full()
348            .child(SectionHeader::new("Recent Projects"))
349            .children(recent_projects)
350    }
351
352    fn render_recent_project(
353        &self,
354        project_index: usize,
355        tab_index: usize,
356        location: &SerializedWorkspaceLocation,
357        paths: &PathList,
358    ) -> impl IntoElement {
359        let (icon, title) = match location {
360            SerializedWorkspaceLocation::Local => {
361                let path = paths.paths().first().map(|p| p.as_path());
362                let name = path
363                    .and_then(|p| p.file_name())
364                    .map(|n| n.to_string_lossy().to_string())
365                    .unwrap_or_else(|| "Untitled".to_string());
366                (IconName::Folder, name)
367            }
368            SerializedWorkspaceLocation::Remote(_) => {
369                (IconName::Server, "Remote Project".to_string())
370            }
371        };
372
373        SectionButton::new(
374            title,
375            icon,
376            &OpenRecentProject {
377                index: project_index,
378            },
379            tab_index,
380            self.focus_handle.clone(),
381        )
382    }
383}
384
385impl Render for WelcomePage {
386    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
387        let (first_section, second_section) = CONTENT;
388        let first_section_entries = first_section.entries.len();
389        let last_index = first_section_entries + second_section.entries.len();
390
391        let recent_projects = self
392            .recent_workspaces
393            .as_ref()
394            .into_iter()
395            .flatten()
396            .take(5)
397            .enumerate()
398            .map(|(index, (_, loc, paths, _))| {
399                self.render_recent_project(index, first_section_entries + index, loc, paths)
400            })
401            .collect::<Vec<_>>();
402
403        let second_section = if self.fallback_to_recent_projects && !recent_projects.is_empty() {
404            self.render_recent_project_section(recent_projects)
405                .into_any_element()
406        } else {
407            second_section
408                .render(first_section_entries, &self.focus_handle, cx)
409                .into_any_element()
410        };
411
412        let welcome_label = if self.fallback_to_recent_projects {
413            "Welcome back to Zed"
414        } else {
415            "Welcome to Zed"
416        };
417
418        h_flex()
419            .key_context("Welcome")
420            .track_focus(&self.focus_handle(cx))
421            .on_action(cx.listener(Self::select_previous))
422            .on_action(cx.listener(Self::select_next))
423            .on_action(cx.listener(Self::open_recent_project))
424            .size_full()
425            .justify_center()
426            .overflow_hidden()
427            .bg(cx.theme().colors().editor_background)
428            .child(
429                h_flex()
430                    .relative()
431                    .size_full()
432                    .px_12()
433                    .max_w(px(1100.))
434                    .child(
435                        v_flex()
436                            .flex_1()
437                            .justify_center()
438                            .max_w_128()
439                            .mx_auto()
440                            .gap_6()
441                            .overflow_x_hidden()
442                            .child(
443                                h_flex()
444                                    .w_full()
445                                    .justify_center()
446                                    .mb_4()
447                                    .gap_4()
448                                    .child(Vector::square(VectorName::ZedLogo, rems_from_px(45.)))
449                                    .child(
450                                        v_flex().child(Headline::new(welcome_label)).child(
451                                            Label::new("The editor for what's next")
452                                                .size(LabelSize::Small)
453                                                .color(Color::Muted)
454                                                .italic(),
455                                        ),
456                                    ),
457                            )
458                            .child(first_section.render(Default::default(), &self.focus_handle, cx))
459                            .child(second_section)
460                            .when(!self.fallback_to_recent_projects, |this| {
461                                this.child(
462                                    v_flex().gap_1().child(Divider::horizontal()).child(
463                                        Button::new("welcome-exit", "Return to Onboarding")
464                                            .tab_index(last_index as isize)
465                                            .full_width()
466                                            .label_size(LabelSize::XSmall)
467                                            .on_click(|_, window, cx| {
468                                                window.dispatch_action(
469                                                    OpenOnboarding.boxed_clone(),
470                                                    cx,
471                                                );
472                                            }),
473                                    ),
474                                )
475                            }),
476                    ),
477            )
478    }
479}
480
481impl EventEmitter<ItemEvent> for WelcomePage {}
482
483impl Focusable for WelcomePage {
484    fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
485        self.focus_handle.clone()
486    }
487}
488
489impl Item for WelcomePage {
490    type Event = ItemEvent;
491
492    fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
493        "Welcome".into()
494    }
495
496    fn telemetry_event_text(&self) -> Option<&'static str> {
497        Some("New Welcome Page Opened")
498    }
499
500    fn show_toolbar(&self) -> bool {
501        false
502    }
503
504    fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(crate::item::ItemEvent)) {
505        f(*event)
506    }
507}
508
509impl crate::SerializableItem for WelcomePage {
510    fn serialized_item_kind() -> &'static str {
511        "WelcomePage"
512    }
513
514    fn cleanup(
515        workspace_id: crate::WorkspaceId,
516        alive_items: Vec<crate::ItemId>,
517        _window: &mut Window,
518        cx: &mut App,
519    ) -> Task<gpui::Result<()>> {
520        crate::delete_unloaded_items(
521            alive_items,
522            workspace_id,
523            "welcome_pages",
524            &persistence::WELCOME_PAGES,
525            cx,
526        )
527    }
528
529    fn deserialize(
530        _project: Entity<project::Project>,
531        workspace: gpui::WeakEntity<Workspace>,
532        workspace_id: crate::WorkspaceId,
533        item_id: crate::ItemId,
534        window: &mut Window,
535        cx: &mut App,
536    ) -> Task<gpui::Result<Entity<Self>>> {
537        if persistence::WELCOME_PAGES
538            .get_welcome_page(item_id, workspace_id)
539            .ok()
540            .is_some_and(|is_open| is_open)
541        {
542            Task::ready(Ok(
543                cx.new(|cx| WelcomePage::new(workspace, false, window, cx))
544            ))
545        } else {
546            Task::ready(Err(anyhow::anyhow!("No welcome page to deserialize")))
547        }
548    }
549
550    fn serialize(
551        &mut self,
552        workspace: &mut Workspace,
553        item_id: crate::ItemId,
554        _closing: bool,
555        _window: &mut Window,
556        cx: &mut Context<Self>,
557    ) -> Option<Task<gpui::Result<()>>> {
558        let workspace_id = workspace.database_id()?;
559        Some(cx.background_spawn(async move {
560            persistence::WELCOME_PAGES
561                .save_welcome_page(item_id, workspace_id, true)
562                .await
563        }))
564    }
565
566    fn should_serialize(&self, event: &Self::Event) -> bool {
567        event == &ItemEvent::UpdateTab
568    }
569}
570
571mod persistence {
572    use crate::WorkspaceDb;
573    use db::{
574        query,
575        sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
576        sqlez_macros::sql,
577    };
578
579    pub struct WelcomePagesDb(ThreadSafeConnection);
580
581    impl Domain for WelcomePagesDb {
582        const NAME: &str = stringify!(WelcomePagesDb);
583
584        const MIGRATIONS: &[&str] = (&[sql!(
585                    CREATE TABLE welcome_pages (
586                        workspace_id INTEGER,
587                        item_id INTEGER UNIQUE,
588                        is_open INTEGER DEFAULT FALSE,
589
590                        PRIMARY KEY(workspace_id, item_id),
591                        FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
592                        ON DELETE CASCADE
593                    ) STRICT;
594        )]);
595    }
596
597    db::static_connection!(WELCOME_PAGES, WelcomePagesDb, [WorkspaceDb]);
598
599    impl WelcomePagesDb {
600        query! {
601            pub async fn save_welcome_page(
602                item_id: crate::ItemId,
603                workspace_id: crate::WorkspaceId,
604                is_open: bool
605            ) -> Result<()> {
606                INSERT OR REPLACE INTO welcome_pages(item_id, workspace_id, is_open)
607                VALUES (?, ?, ?)
608            }
609        }
610
611        query! {
612            pub fn get_welcome_page(
613                item_id: crate::ItemId,
614                workspace_id: crate::WorkspaceId
615            ) -> Result<bool> {
616                SELECT is_open
617                FROM welcome_pages
618                WHERE item_id = ? AND workspace_id = ?
619            }
620        }
621    }
622}