welcome.rs

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