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