welcome.rs

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