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_project_workspaces(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 name = project_name(paths);
403
404        let (icon, title) = match location {
405            SerializedWorkspaceLocation::Local => (IconName::Folder, name),
406            SerializedWorkspaceLocation::Remote(_) => (IconName::Server, name),
407        };
408
409        SectionButton::new(
410            title,
411            icon,
412            &OpenRecentProject {
413                index: project_index,
414            },
415            tab_index,
416            self.focus_handle.clone(),
417        )
418    }
419}
420
421impl Render for WelcomePage {
422    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
423        let (first_section, second_section) = CONTENT;
424        let first_section_entries = first_section.entries.len();
425        let mut next_tab_index = first_section_entries + second_section.entries.len();
426
427        let ai_enabled = AgentSettings::get_global(cx).enabled(cx);
428
429        let recent_projects = self
430            .recent_workspaces
431            .as_ref()
432            .into_iter()
433            .flatten()
434            .take(5)
435            .enumerate()
436            .map(|(index, (_, loc, paths, _))| {
437                self.render_recent_project(index, first_section_entries + index, loc, paths)
438            })
439            .collect::<Vec<_>>();
440
441        let showing_recent_projects =
442            self.fallback_to_recent_projects && !recent_projects.is_empty();
443        let second_section = if showing_recent_projects {
444            self.render_recent_project_section(recent_projects)
445                .into_any_element()
446        } else {
447            second_section
448                .render(first_section_entries, &self.focus_handle)
449                .into_any_element()
450        };
451
452        let welcome_label = if self.fallback_to_recent_projects {
453            "Welcome back to Zed"
454        } else {
455            "Welcome to Zed"
456        };
457
458        h_flex()
459            .key_context("Welcome")
460            .track_focus(&self.focus_handle(cx))
461            .on_action(cx.listener(Self::select_previous))
462            .on_action(cx.listener(Self::select_next))
463            .on_action(cx.listener(Self::open_recent_project))
464            .size_full()
465            .bg(cx.theme().colors().editor_background)
466            .justify_center()
467            .child(
468                v_flex()
469                    .id("welcome-content")
470                    .p_8()
471                    .max_w_128()
472                    .size_full()
473                    .gap_6()
474                    .justify_center()
475                    .overflow_y_scroll()
476                    .child(
477                        h_flex()
478                            .w_full()
479                            .justify_center()
480                            .mb_4()
481                            .gap_4()
482                            .child(Vector::square(VectorName::ZedLogo, rems_from_px(45.)))
483                            .child(
484                                v_flex().child(Headline::new(welcome_label)).child(
485                                    Label::new("The editor for what's next")
486                                        .size(LabelSize::Small)
487                                        .color(Color::Muted)
488                                        .italic(),
489                                ),
490                            ),
491                    )
492                    .child(first_section.render(Default::default(), &self.focus_handle))
493                    .child(second_section)
494                    .when(ai_enabled && !showing_recent_projects, |this| {
495                        let agent_tab_index = next_tab_index;
496                        next_tab_index += 1;
497                        this.child(self.render_agent_card(agent_tab_index, cx))
498                    })
499                    .when(!self.fallback_to_recent_projects, |this| {
500                        this.child(
501                            v_flex().gap_4().child(Divider::horizontal()).child(
502                                Button::new("welcome-exit", "Return to Onboarding")
503                                    .tab_index(next_tab_index as isize)
504                                    .full_width()
505                                    .label_size(LabelSize::XSmall)
506                                    .on_click(|_, window, cx| {
507                                        window.dispatch_action(OpenOnboarding.boxed_clone(), cx);
508                                    }),
509                            ),
510                        )
511                    }),
512            )
513    }
514}
515
516impl EventEmitter<ItemEvent> for WelcomePage {}
517
518impl Focusable for WelcomePage {
519    fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
520        self.focus_handle.clone()
521    }
522}
523
524impl Item for WelcomePage {
525    type Event = ItemEvent;
526
527    fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
528        "Welcome".into()
529    }
530
531    fn telemetry_event_text(&self) -> Option<&'static str> {
532        Some("New Welcome Page Opened")
533    }
534
535    fn show_toolbar(&self) -> bool {
536        false
537    }
538
539    fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(crate::item::ItemEvent)) {
540        f(*event)
541    }
542}
543
544impl crate::SerializableItem for WelcomePage {
545    fn serialized_item_kind() -> &'static str {
546        "WelcomePage"
547    }
548
549    fn cleanup(
550        workspace_id: crate::WorkspaceId,
551        alive_items: Vec<crate::ItemId>,
552        _window: &mut Window,
553        cx: &mut App,
554    ) -> Task<gpui::Result<()>> {
555        crate::delete_unloaded_items(
556            alive_items,
557            workspace_id,
558            "welcome_pages",
559            &persistence::WelcomePagesDb::global(cx),
560            cx,
561        )
562    }
563
564    fn deserialize(
565        _project: Entity<project::Project>,
566        workspace: gpui::WeakEntity<Workspace>,
567        workspace_id: crate::WorkspaceId,
568        item_id: crate::ItemId,
569        window: &mut Window,
570        cx: &mut App,
571    ) -> Task<gpui::Result<Entity<Self>>> {
572        if persistence::WelcomePagesDb::global(cx)
573            .get_welcome_page(item_id, workspace_id)
574            .ok()
575            .is_some_and(|is_open| is_open)
576        {
577            Task::ready(Ok(
578                cx.new(|cx| WelcomePage::new(workspace, false, window, cx))
579            ))
580        } else {
581            Task::ready(Err(anyhow::anyhow!("No welcome page to deserialize")))
582        }
583    }
584
585    fn serialize(
586        &mut self,
587        workspace: &mut Workspace,
588        item_id: crate::ItemId,
589        _closing: bool,
590        _window: &mut Window,
591        cx: &mut Context<Self>,
592    ) -> Option<Task<gpui::Result<()>>> {
593        let workspace_id = workspace.database_id()?;
594        let db = persistence::WelcomePagesDb::global(cx);
595        Some(cx.background_spawn(
596            async move { db.save_welcome_page(item_id, workspace_id, true).await },
597        ))
598    }
599
600    fn should_serialize(&self, event: &Self::Event) -> bool {
601        event == &ItemEvent::UpdateTab
602    }
603}
604
605mod persistence {
606    use crate::WorkspaceDb;
607    use db::{
608        query,
609        sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
610        sqlez_macros::sql,
611    };
612
613    pub struct WelcomePagesDb(ThreadSafeConnection);
614
615    impl Domain for WelcomePagesDb {
616        const NAME: &str = stringify!(WelcomePagesDb);
617
618        const MIGRATIONS: &[&str] = (&[sql!(
619                    CREATE TABLE welcome_pages (
620                        workspace_id INTEGER,
621                        item_id INTEGER UNIQUE,
622                        is_open INTEGER DEFAULT FALSE,
623
624                        PRIMARY KEY(workspace_id, item_id),
625                        FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
626                        ON DELETE CASCADE
627                    ) STRICT;
628        )]);
629    }
630
631    db::static_connection!(WelcomePagesDb, [WorkspaceDb]);
632
633    impl WelcomePagesDb {
634        query! {
635            pub async fn save_welcome_page(
636                item_id: crate::ItemId,
637                workspace_id: crate::WorkspaceId,
638                is_open: bool
639            ) -> Result<()> {
640                INSERT OR REPLACE INTO welcome_pages(item_id, workspace_id, is_open)
641                VALUES (?, ?, ?)
642            }
643        }
644
645        query! {
646            pub fn get_welcome_page(
647                item_id: crate::ItemId,
648                workspace_id: crate::WorkspaceId
649            ) -> Result<bool> {
650                SELECT is_open
651                FROM welcome_pages
652                WHERE item_id = ? AND workspace_id = ?
653            }
654        }
655    }
656}
657
658fn project_name(paths: &PathList) -> String {
659    let joined = paths
660        .paths()
661        .iter()
662        .filter_map(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
663        .collect::<Vec<_>>()
664        .join(", ");
665    if joined.is_empty() {
666        "Untitled".to_string()
667    } else {
668        joined
669    }
670}
671
672#[cfg(test)]
673mod tests {
674    use super::*;
675
676    #[test]
677    fn test_project_name_empty() {
678        let paths = PathList::new::<&str>(&[]);
679        assert_eq!(project_name(&paths), "Untitled");
680    }
681
682    #[test]
683    fn test_project_name_single() {
684        let paths = PathList::new(&["/home/user/my-project"]);
685        assert_eq!(project_name(&paths), "my-project");
686    }
687
688    #[test]
689    fn test_project_name_multiple() {
690        // PathList sorts lexicographically, so filenames appear in alpha order
691        let paths = PathList::new(&["/home/user/zed", "/home/user/api"]);
692        assert_eq!(project_name(&paths), "api, zed");
693    }
694
695    #[test]
696    fn test_project_name_root_path_filtered() {
697        // A bare root "/" has no file_name(), falls back to "Untitled"
698        let paths = PathList::new(&["/"]);
699        assert_eq!(project_name(&paths), "Untitled");
700    }
701}