welcome.rs

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