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