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