tip of the day

cameron created

Change summary

crates/git_ui/src/git_ui.rs           |  15 ++
crates/settings_ui/src/settings_ui.rs |  10 +
crates/workspace/src/welcome.rs       | 216 ++++++++++++++++++++++++++++
crates/workspace/src/workspace.rs     |  17 ++
4 files changed, 252 insertions(+), 6 deletions(-)

Detailed changes

crates/git_ui/src/git_ui.rs 🔗

@@ -47,10 +47,25 @@ pub mod stash_picker;
 pub mod text_diff_view;
 pub mod worktree_picker;
 
+const BRANCH_DIFF_TIP_MESSAGE: &str = "
+Want to review your changes but you've already made some commits? Use the Branch \
+Diff to see the changes compared to the default branch.
+";
+
 pub fn init(cx: &mut App) {
     editor::set_blame_renderer(blame_ui::GitBlameRenderer, cx);
     commit_view::init(cx);
 
+    workspace::welcome::register_tip(
+        workspace::welcome::Tip {
+            title: "Review Your Branch Diff".into(),
+            message: BRANCH_DIFF_TIP_MESSAGE.into(),
+            icon: Some(ui::IconName::GitBranch),
+            mentioned_actions: vec![Box::new(project_diff::BranchDiff)],
+        },
+        cx,
+    );
+
     cx.observe_new(|editor: &mut Editor, _, cx| {
         conflict_view::register_editor(editor, editor.buffer().clone(), cx);
     })

crates/settings_ui/src/settings_ui.rs 🔗

@@ -387,11 +387,21 @@ struct SettingsFieldMetadata {
     should_do_titlecase: Option<bool>,
 }
 
+// const SETTINGS_PROFILES_TIP_MESSAGE: &str = "
+
+// ";
+
 pub fn init(cx: &mut App) {
     init_renderers(cx);
     let queue = ProjectSettingsUpdateQueue::new(cx);
     cx.set_global(queue);
 
+    // workspace::welcome::register_tip(workspace::welcome::Tip {
+    //     icon: Some(IconName::Settings),
+    //     title: "Quickly switch settings profiles".into(),
+    //     message
+    // }, cx);
+
     cx.observe_new(|workspace: &mut workspace::Workspace, _, _| {
         workspace
             .register_action(

crates/workspace/src/welcome.rs 🔗

@@ -2,19 +2,21 @@ use crate::{
     NewFile, Open, PathList, SerializedWorkspaceLocation, WORKSPACE_DB, Workspace, WorkspaceId,
     item::{Item, ItemEvent},
 };
-use chrono::{DateTime, Utc};
+use chrono::{DateTime, Datelike, Utc};
 use git::Clone as GitClone;
 use gpui::WeakEntity;
 use gpui::{
-    Action, App, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
-    ParentElement, Render, Styled, Task, Window, actions,
+    Action, App, Context, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, Global,
+    InteractiveElement, ParentElement, Render, Styled, Task, Window, actions,
 };
 use menu::{SelectNext, SelectPrevious};
 use project::DisableAiSettings;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use settings::Settings;
-use ui::{ButtonLike, Divider, DividerColor, KeyBinding, Vector, VectorName, prelude::*};
+use ui::{
+    ButtonLike, Divider, DividerColor, IconButtonShape, KeyBinding, Vector, VectorName, prelude::*,
+};
 use util::ResultExt;
 use zed_actions::{Extensions, OpenOnboarding, OpenSettings, agent, command_palette};
 
@@ -33,6 +35,70 @@ actions!(
     ]
 );
 
+actions!(
+    welcome,
+    [
+        /// Show the next tip of the day
+        NextTip,
+        /// Show the previous tip of the day
+        PreviousTip,
+    ]
+);
+
+pub struct Tip {
+    pub title: SharedString,
+    pub message: SharedString,
+    pub icon: Option<IconName>,
+    pub mentioned_actions: Vec<Box<dyn Action>>,
+}
+
+#[derive(Default)]
+struct TipRegistry {
+    tips: Vec<Tip>,
+}
+
+impl Global for TipRegistry {}
+
+pub fn register_tip(tip: Tip, cx: &mut App) {
+    cx.default_global::<TipRegistry>().tips.push(tip);
+}
+
+fn humanize_action_name(name: &str) -> String {
+    let capacity = name.len() + name.chars().filter(|c| c.is_uppercase()).count();
+    let mut result = String::with_capacity(capacity);
+    for char in name.chars() {
+        if char == ':' {
+            if result.ends_with(':') {
+                result.push(' ');
+            } else {
+                result.push(':');
+            }
+        } else if char == '_' {
+            result.push(' ');
+        } else if char.is_uppercase() {
+            if !result.ends_with(' ') {
+                result.push(' ');
+            }
+            result.extend(char.to_lowercase());
+        } else {
+            result.push(char);
+        }
+    }
+    result
+}
+
+fn tip_index_for_today(cx: &App) -> usize {
+    let Some(registry) = cx.try_global::<TipRegistry>() else {
+        return 0;
+    };
+    let count = registry.tips.len();
+    if count == 0 {
+        return 0;
+    }
+    let today = Utc::now().date_naive();
+    today.num_days_from_ce() as usize % count
+}
+
 #[derive(IntoElement)]
 struct SectionHeader {
     title: SharedString,
@@ -246,6 +312,7 @@ pub struct WelcomePage {
     workspace: WeakEntity<Workspace>,
     focus_handle: FocusHandle,
     fallback_to_recent_projects: bool,
+    tip_index: usize,
     recent_workspaces: Option<
         Vec<(
             WorkspaceId,
@@ -292,6 +359,7 @@ impl WelcomePage {
             workspace,
             focus_handle,
             fallback_to_recent_projects,
+            tip_index: tip_index_for_today(cx),
             recent_workspaces: None,
         }
     }
@@ -306,6 +374,26 @@ impl WelcomePage {
         cx.notify();
     }
 
+    fn next_tip(&mut self, _: &NextTip, _window: &mut Window, cx: &mut Context<Self>) {
+        let count = cx
+            .try_global::<TipRegistry>()
+            .map_or(0, |r| r.tips.len());
+        if count > 0 {
+            self.tip_index = (self.tip_index + 1) % count;
+            cx.notify();
+        }
+    }
+
+    fn previous_tip(&mut self, _: &PreviousTip, _window: &mut Window, cx: &mut Context<Self>) {
+        let count = cx
+            .try_global::<TipRegistry>()
+            .map_or(0, |r| r.tips.len());
+        if count > 0 {
+            self.tip_index = (self.tip_index + count - 1) % count;
+            cx.notify();
+        }
+    }
+
     fn open_recent_project(
         &mut self,
         action: &OpenRecentProject,
@@ -377,6 +465,115 @@ impl WelcomePage {
             self.focus_handle.clone(),
         )
     }
+
+    fn render_tip_section(&self, cx: &App) -> Option<impl IntoElement> {
+        let registry = cx.try_global::<TipRegistry>()?;
+        let tip = registry.tips.get(self.tip_index)?;
+        let focus = &self.focus_handle;
+
+        Some(
+            v_flex()
+                .w_full()
+                .p_4()
+                .border_1()
+                .border_color(cx.theme().colors().border_variant)
+                .rounded_md()
+                .bg(cx.theme().colors().surface_background)
+                .gap_3()
+                .child(
+                    h_flex()
+                        .justify_between()
+                        .items_center()
+                        .child(
+                            Label::new("TIP OF THE DAY")
+                                .buffer_font(cx)
+                                .color(Color::Muted)
+                                .size(LabelSize::XSmall),
+                        )
+                        .child(
+                            h_flex()
+                                .gap_1()
+                                .items_center()
+                                .child(
+                                    IconButton::new("prev-tip", IconName::ChevronLeft)
+                                        .shape(IconButtonShape::Square)
+                                        .icon_size(IconSize::Small)
+                                        .on_click(|_, window, cx| {
+                                            window
+                                                .dispatch_action(PreviousTip.boxed_clone(), cx);
+                                        }),
+                                )
+                                .child(
+                                    IconButton::new("next-tip", IconName::ChevronRight)
+                                        .shape(IconButtonShape::Square)
+                                        .icon_size(IconSize::Small)
+                                        .on_click(|_, window, cx| {
+                                            window
+                                                .dispatch_action(NextTip.boxed_clone(), cx);
+                                        }),
+                                ),
+                        ),
+                )
+                .child(
+                    h_flex()
+                        .gap_2()
+                        .items_center()
+                        .when_some(tip.icon, |this, icon| {
+                            this.child(
+                                Icon::new(icon).size(IconSize::Medium).color(Color::Accent),
+                            )
+                        })
+                        .child(
+                            Label::new(tip.title.clone())
+                                .weight(FontWeight::BOLD)
+                                .size(LabelSize::Large),
+                        ),
+                )
+                .child(
+                    Label::new(tip.message.trim().to_string())
+                        .color(Color::Muted)
+                        .size(LabelSize::Small),
+                )
+                .when(!tip.mentioned_actions.is_empty(), |this| {
+                    this.child(
+                        h_flex()
+                            .gap_2()
+                            .items_center()
+                            .flex_wrap()
+                            .child(
+                                Label::new("Try it out:")
+                                    .color(Color::Muted)
+                                    .size(LabelSize::Small)
+                                    .weight(FontWeight::BOLD),
+                            )
+                            .children(tip.mentioned_actions.iter().map(|action| {
+                                let action_name = humanize_action_name(action.name());
+                                let action_clone = action.boxed_clone();
+                                let focus_handle = focus.clone();
+                                ButtonLike::new(SharedString::from(format!(
+                                    "tip-action-{action_name}"
+                                )))
+                                .size(ButtonSize::Compact)
+                                .child(
+                                    h_flex()
+                                        .gap_1p5()
+                                        .items_center()
+                                        .child(
+                                            Label::new(action_name).size(LabelSize::Small),
+                                        )
+                                        .child(KeyBinding::for_action_in(
+                                            action.as_ref(), focus, cx,
+                                        )),
+                                )
+                                .on_click(move |_, window, cx| {
+                                    focus_handle
+                                        .dispatch_action(&*action_clone, window, cx);
+                                })
+                            })),
+                    )
+                }),
+        )
+    }
 }
 
 impl Render for WelcomePage {
@@ -418,6 +615,8 @@ impl Render for WelcomePage {
             .on_action(cx.listener(Self::select_previous))
             .on_action(cx.listener(Self::select_next))
             .on_action(cx.listener(Self::open_recent_project))
+            .on_action(cx.listener(Self::next_tip))
+            .on_action(cx.listener(Self::previous_tip))
             .size_full()
             .justify_center()
             .overflow_hidden()
@@ -430,12 +629,15 @@ impl Render for WelcomePage {
                     .max_w(px(1100.))
                     .child(
                         v_flex()
+                            .id("welcome-content")
                             .flex_1()
-                            .justify_center()
+                            .h_full()
                             .max_w_128()
                             .mx_auto()
                             .gap_6()
                             .overflow_x_hidden()
+                            .overflow_y_scroll()
+                            .child(div().flex_grow())
                             .child(
                                 h_flex()
                                     .w_full()
@@ -454,6 +656,7 @@ impl Render for WelcomePage {
                             )
                             .child(first_section.render(Default::default(), &self.focus_handle, cx))
                             .child(second_section)
+                            .children(self.render_tip_section(cx))
                             .when(!self.fallback_to_recent_projects, |this| {
                                 this.child(
                                     v_flex().gap_1().child(Divider::horizontal()).child(
@@ -469,7 +672,8 @@ impl Render for WelcomePage {
                                             }),
                                     ),
                                 )
-                            }),
+                            })
+                            .child(div().flex_grow()),
                     ),
             )
     }

crates/workspace/src/workspace.rs 🔗

@@ -723,6 +723,23 @@ pub fn init(app_state: Arc<AppState>, cx: &mut App) {
     toast_layer::init(cx);
     history_manager::init(app_state.fs.clone(), cx);
 
+    welcome::register_tip(
+        welcome::Tip {
+            title: "Master the Command Palette".into(),
+            message: "The command palette is your gateway to everything in Zed. Instead of \
+                hunting through menus, you can quickly find and execute any command by typing a \
+                few letters of its name. It supports fuzzy matching, so you don't need to \
+                remember exact command names. Whether you want to change your theme, toggle a \
+                panel, run a task, or trigger a Git operation, the command palette has you \
+                covered. Try building muscle memory by using it for actions you'd normally reach \
+                for with a mouse."
+                .into(),
+            icon: Some(ui::IconName::Sparkle),
+            mentioned_actions: vec![Box::new(zed_actions::command_palette::Toggle)],
+        },
+        cx,
+    );
+
     cx.on_action(|_: &CloseWindow, cx| Workspace::close_global(cx))
         .on_action(|_: &Reload, cx| reload(cx))
         .on_action({