use std::collections::HashSet; use chrono::{DateTime, FixedOffset}; use settings::Setting as _; use crate::report_if_error; use crate::terminal::general_settings::GeneralSettings; use crate::util::bindings::trigger_to_keystroke; mod main_page; pub mod utils; pub use main_page::{ResourceCenterMainEvent, ResourceCenterMainView}; mod keybindings_page; pub use keybindings_page::KeybindingsView; mod section_views; pub use section_views::{ChangelogSectionView, ContentSectionView, FeatureSectionView}; pub mod sections; mod view; use galaxyui::keymap::Keystroke; use galaxyui::{AppContext, Entity, SingletonEntity}; use serde::{Deserialize, Serialize}; pub use view::{ResourceCenterAction, ResourceCenterEvent, ResourceCenterPage, ResourceCenterView}; use self::section_views::feature_section::FeatureSection; #[derive( Clone, Copy, Debug, Hash, PartialEq, std::cmp::Eq, Serialize, Deserialize, schemars::JsonSchema, settings_value::SettingsValue, )] #[schemars( description = "A welcome tip shown to new users.", rename_all = "snake_case" )] pub enum Tip { #[schemars(description = "A non-interactive informational hint.")] Hint(TipHint), #[schemars(description = "An interactive tip that triggers an action when clicked.")] Action(TipAction), } // Tips that aren't clickable to dispatch an action #[derive( Clone, Copy, Debug, Hash, PartialEq, std::cmp::Eq, Serialize, Deserialize, schemars::JsonSchema, settings_value::SettingsValue, )] #[schemars(description = "A non-interactive tip hint.", rename_all = "snake_case")] pub enum TipHint { CreateBlock, BlockSelect, BlockAction, } // Tips that are clickable and dispatch an action #[derive( Clone, Copy, Debug, Hash, PartialEq, std::cmp::Eq, Serialize, Deserialize, schemars::JsonSchema, settings_value::SettingsValue, )] #[schemars(description = "An interactive tip action.", rename_all = "snake_case")] pub enum TipAction { CommandPalette, SplitPane, ThemePicker, HistorySearch, CommandSearch, AiCommandSearch, SaveNewLaunchConfig, WarpAI, // This toggles Warp Drive rather than opening it. This enum can't directly be // renamed because we serialize it into the welcome tips. OpenGalaxyDrive, Changelog, // Note that this item has been deprecated from the UI and is not in any section. // We are leaving it in this enum to ensure that we don't re-use `Workflows` as a // value. Since old clients will have this value in their user defaults, we want // to prevent future usage of this enum value. Workflows, } impl TipAction { pub fn editable_binding_name(&self) -> &'static str { match self { TipAction::CommandPalette => "workspace:toggle_command_palette", TipAction::SplitPane => "pane_group:add_right", TipAction::HistorySearch => "input:search_command_history", TipAction::CommandSearch => "workspace:show_command_search", TipAction::AiCommandSearch => "input:toggle_natural_language_command_search", TipAction::ThemePicker => "workspace:show_theme_chooser", TipAction::SaveNewLaunchConfig => "workspace:open_launch_config_save_modal", TipAction::WarpAI => "workspace:toggle_ai_assistant", TipAction::OpenGalaxyDrive => "workspace:toggle_left_panel", // Slash commands are also registered as editable bindings, so callers can look them up here // the same way they do regular app actions. TipAction::Changelog => "/changelog", TipAction::Workflows => "input:toggle_workflows", } } pub fn keyboard_shortcut(&self, ctx: &mut AppContext) -> Option { ctx.editable_bindings() .find(|binding| binding.name == self.editable_binding_name()) .and_then(|binding| trigger_to_keystroke(binding.trigger)) } } #[derive(Clone, Debug, PartialEq, Eq)] // Section item that dispatches an action within the app pub struct FeatureItem { pub title: &'static str, pub description: &'static str, pub feature: Tip, pub editable_binding_name: Option<&'static str>, pub shortcut: Option, } impl FeatureItem { pub fn new( title: &'static str, description: &'static str, feature: Tip, ctx: &mut AppContext, ) -> Self { let (editable_binding_name, shortcut) = match feature { Tip::Hint(_) => (None, None), Tip::Action(tip) => ( Some(tip.editable_binding_name()), tip.keyboard_shortcut(ctx), ), }; Self { title, description, feature, editable_binding_name, shortcut, } } } #[derive(Clone, Debug)] // Section item that links to an external URL pub struct ContentItem { pub title: &'static str, pub description: &'static str, pub url: &'static str, pub button_label: &'static str, } pub enum Section { Feature(FeatureSectionData), Content(ContentSectionData), Changelog(), } #[derive(Clone, Debug, PartialEq, Eq)] pub struct FeatureSectionData { pub section_name: FeatureSection, pub items: Vec, } #[derive(Clone)] pub struct ContentSectionData { pub section_name: FeatureSection, pub items: Vec, } #[derive(Clone)] pub struct ChangelogSectionData { pub section_name: FeatureSection, pub date: DateTime, pub new_features_markdown: String, pub improvements_markdown: String, pub coming_soon_markdown: String, } #[derive(Default)] pub struct TipsCompleted { pub features_used: HashSet, pub skipped_or_completed: bool, pub gamified_tips_count: Option, } impl Entity for TipsCompleted { type Event = (); } impl FeatureSectionData { pub fn is_section_completed(&self, tips_completed: &TipsCompleted) -> bool { self.items .iter() .all(|item| tips_completed.features_used.contains(&item.feature)) } pub fn tips_completed_count(&self, tips_completed: &TipsCompleted) -> usize { self.items .iter() .filter(|item| tips_completed.features_used.contains(&item.feature)) .count() } } /// Marks the welcome tip as used, writes their current state to a cloud synced preference. pub fn mark_feature_used_and_write_to_user_defaults( feature: Tip, tips_completed: &mut TipsCompleted, ctx: &mut AppContext, ) { if tips_completed.mark_feature_used(feature) { GeneralSettings::handle(ctx).update(ctx, |general_settings, ctx| { report_if_error!(general_settings .welcome_tips_features_used .set_value(tips_completed.features_used.clone(), ctx)); if tips_completed.skipped_or_completed { report_if_error!(general_settings .welcome_tips_skipped_or_completed .set_value(true, ctx)); } }); } } /// Updates the model to reflect welcome tips are skipped, writes to user defaults, and sends telemetry. pub fn skip_tips_and_write_to_user_defaults( tips_completed: &mut TipsCompleted, ctx: &mut AppContext, ) { tips_completed.skipped_or_completed = true; GeneralSettings::handle(ctx).update(ctx, |general_settings, ctx| { report_if_error!(general_settings .welcome_tips_skipped_or_completed .set_value(true, ctx)); }); } /// Updates the model to reflect welcome tips are skipped, writes to user defaults, and sends telemetry. pub fn complete_tips_and_write_to_user_defaults( tips_completed: &mut TipsCompleted, ctx: &mut AppContext, ) { tips_completed.skipped_or_completed = true; GeneralSettings::handle(ctx).update(ctx, |general_settings, ctx| { report_if_error!(general_settings .welcome_tips_skipped_or_completed .set_value(true, ctx)); }); } impl TipsCompleted { pub fn new(features_used: HashSet, skipped_or_completed: bool) -> Self { Self { features_used, skipped_or_completed, gamified_tips_count: None, } } /// Returns true if the feature previously wasn't used. pub fn mark_feature_used(&mut self, feature: Tip) -> bool { let is_new_value = self.features_used.insert(feature); // Check if all gamified tips are completed if let Some(total_tips) = self.gamified_tips_count { if is_new_value && self.features_used.len() == total_tips { self.skipped_or_completed = true; } } is_new_value } pub fn serialized_tips(&self) -> Result { serde_json::to_string(&self.features_used) } pub fn completed_count(&self) -> usize { self.features_used.len() } pub fn set_gamified_tips_count(&mut self, total: usize) { self.gamified_tips_count = Some(total) } }