use std::borrow::Cow; use std::cell::RefCell; use std::collections::{HashMap, HashSet}; use std::sync::LazyLock; use std::time::Duration; use pathfinder_geometry::vector::vec2f; use galaxy_core::ui::theme::color::internal_colors; use galaxyui::r#async::{SpawnedFutureHandle, Timer}; use regex::Regex; use settings::Setting as _; use galaxy_core::context_flag::ContextFlag; use galaxy_core::features::FeatureFlag; use galaxy_core::ui::theme::WarpTheme; use galaxyui::elements::{ Align, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty, Expanded, Flex, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius, Rect, Shrinkable, Stack, Text, }; use galaxyui::keymap::ContextPredicate; use galaxyui::platform::Cursor; use galaxyui::ui_components::button::{ButtonVariant, TextAndIcon, TextAndIconAlignment}; use galaxyui::ui_components::{ components::{Coords, UiComponent, UiComponentStyles}, switch::{SwitchStateHandle, TooltipConfig}, }; use galaxyui::{ Action, AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, UpdateModel, View, ViewContext, ViewHandle, }; use crate::settings::{CustomSecretRegex, RegexDisplayInfo}; use crate::settings_view::privacy::AddRegexModalViewState; use crate::settings_view::render_body_item_label; use crate::settings_view::settings_page::CONTENT_FONT_SIZE; use crate::terminal::safe_mode_settings::{ get_effective_secret_display_mode, SecretDisplayMode, SecretDisplayModeSetting, }; use crate::ui_components::buttons::icon_button; use crate::view_components::{Dropdown, DropdownItem}; use crate::{ appearance::Appearance, channel::ChannelState, report_if_error, send_telemetry_from_ctx, server::telemetry::TelemetryEvent, settings::{AISettings, PrivacySettings}, terminal::safe_mode_settings::{SafeModeEnabled, SafeModeSettings}, ui_components::icons::Icon, workspaces::{ user_workspaces::UserWorkspaces, workspace::{CustomerType, UgcCollectionEnablementSetting}, }, }; use super::{ flags, privacy::{AddRegexModal, AddRegexModalEvent}, settings_page::{ render_body_item, render_sub_header, SettingsPageMeta, SettingsPageViewHandle, ToggleState, HEADER_PADDING, TOGGLE_BUTTON_RIGHT_PADDING, }, settings_page::{LocalOnlyIconState, MatchData, PageType, SettingsWidget, PAGE_PADDING}, SettingsAction, SettingsSection, ToggleSettingActionPair, }; use crate::modal::{Modal, ModalEvent, ModalViewState}; use galaxyui::fonts::Weight; const FONT_SIZE: f32 = 12.; const SAFE_MODE_TITLE: &str = "Secret redaction"; static SAFE_MODE_DESCRIPTION: LazyLock<&'static str> = LazyLock::new(|| { "When this setting is enabled, Warp will scan blocks, the contents of \ Warp Drive objects, and Oz prompts for potential sensitive \ information and prevent saving or sending this data to any \ servers. You can customize this list via regexes." }); const USER_SECRET_REGEX_TITLE: &str = "Custom secret redaction"; const USER_SECRET_REGEX_DESCRIPTION: &str = "Use regex to define additional secrets or data you'd like to redact. This will take effect \ when the next command runs. You can use the inline (?i) flag as a prefix to your regex \ to make it case-insensitive."; const TELEMETRY_DESCRIPTION_OLD: &str = "App analytics help us make the product better for you. We only collect \ app usage metadata, never console input or output."; const TELEMETRY_TITLE: &str = "Help improve Warp"; const TELEMETRY_DESCRIPTION: &str = "App analytics help us make the product better for you. We may collect \ certain console interactions to improve Warp's AI capabilities."; const TELEMETRY_FREE_TIER_NOTE: &str = "On the free tier, analytics must be enabled to use AI features."; const TELEMETRY_DOCS_URL: &str = "https://docs.warp.dev/support-and-community/privacy-and-security/privacy#what-telemetry-data-does-warp-collect-and-why"; pub struct PrivacyPageView { page: PageType, local_only_icon_tooltip_states: RefCell>, /// This needs to mirror the length of PrivacySettings::user_secret_regex_list. added_user_secret_regex_list_button_handles: Vec, /// Set of indices for regex items that are pending removal pending_regex_removals: HashSet, /// Handle to the current debounce timer pending_timer: Option, /// Modal state add_regex_modal_state: AddRegexModalViewState, /// Active tab for secret redaction settings active_secret_redaction_tab: SecretRedactionTab, /// Dropdown for selecting secret redaction display mode secret_redaction_display_dropdown: ViewHandle>, } #[derive(Clone, Copy)] pub enum PrivacyPageViewEvent { LaunchNetworkLogging, ShowAddRegexModal, HideAddRegexModal, } impl PrivacyPageView { const BATCH_TIMEOUT_MS: u64 = 700; pub fn new(ctx: &mut ViewContext) -> Self { let privacy_settings_handle = PrivacySettings::handle(ctx); ctx.observe(&privacy_settings_handle, |_, _, ctx| { // It is possible that PrivacySettings are updated without an interaction in this view // (e.g. if the server response fetching settings to be synced is received after the // view is opened), so notify the view if the model is updated. ctx.notify(); }); ctx.observe(&privacy_settings_handle, Self::update_button_states); ctx.subscribe_to_model(&privacy_settings_handle, |me, model, _, ctx| { me.update_button_states(model, ctx); ctx.notify(); }); ctx.subscribe_to_model(&SafeModeSettings::handle(ctx), |me, _, _, ctx| { me.update_secret_display_dropdown(ctx); ctx.notify(); }); let add_regex_body = ctx.add_typed_action_view(AddRegexModal::new); ctx.subscribe_to_view(&add_regex_body, |me, _, event, ctx| { me.handle_add_regex_modal_event(event, ctx); }); let add_regex_modal_view = ctx.add_typed_action_view(|ctx| { Modal::new(Some("Add regex pattern".to_string()), add_regex_body, ctx) .with_modal_style(UiComponentStyles { width: Some(600.), height: Some(400.), ..Default::default() }) .with_header_style(UiComponentStyles { padding: Some(Coords { top: 24., bottom: 0., left: 24., right: 24., }), font_size: Some(16.), font_weight: Some(Weight::Bold), ..Default::default() }) .with_body_style(UiComponentStyles { padding: Some(Coords { top: 0., bottom: 24., left: 24., right: 24., }), ..Default::default() }) .with_background_opacity(100) .with_dismiss_on_click() }); ctx.subscribe_to_view(&add_regex_modal_view, |me, _, event, ctx| { me.handle_modal_event(event, ctx); }); let secret_display_dropdown = ctx.add_typed_action_view(|ctx| { let mut dropdown = Dropdown::new(ctx); dropdown.set_items( SecretDisplayMode::all_modes() .iter() .map(|mode| { DropdownItem::new( mode.display_name(), PrivacyPageAction::SetSecretDisplayMode(*mode), ) }) .collect(), ctx, ); dropdown }); let mut privacy_page_view = Self { page: Self::build_page(), local_only_icon_tooltip_states: Default::default(), added_user_secret_regex_list_button_handles: Default::default(), pending_regex_removals: Default::default(), pending_timer: None, add_regex_modal_state: AddRegexModalViewState::new(ModalViewState::new( add_regex_modal_view, )), active_secret_redaction_tab: SecretRedactionTab::Personal, secret_redaction_display_dropdown: secret_display_dropdown, }; privacy_page_view.update_button_states(privacy_settings_handle, ctx); privacy_page_view.update_secret_display_dropdown(ctx); privacy_page_view } fn build_page() -> PageType { let mut widgets: Vec>> = vec![ Box::new(SecretRedactionWidget::default()), Box::new(AppAnalyticsWidget::default()), Box::new(CrashReportsWidget::default()), ]; if ContextFlag::NetworkLogConsole.is_enabled() { widgets.push(Box::new(NetworkLogWidget::default())); } PageType::new_uncategorized(widgets, Some("Privacy")) } fn update_button_states( &mut self, privacy_settings_handle: ModelHandle, ctx: &mut ViewContext, ) { let privacy_settings = privacy_settings_handle.as_ref(ctx); self.added_user_secret_regex_list_button_handles = privacy_settings .user_secret_regex_list .iter() .map(|_| Default::default()) .collect(); } fn toggle_safe_mode(&mut self, ctx: &mut ViewContext) { let safe_mode_settings = SafeModeSettings::handle(ctx); let new_value = { !*safe_mode_settings.as_ref(ctx).safe_mode_enabled.value() }; send_telemetry_from_ctx!( TelemetryEvent::ToggleSecretRedaction { enabled: new_value }, ctx ); ctx.update_model(&safe_mode_settings, move |safe_mode_settings, ctx| { report_if_error!(safe_mode_settings .safe_mode_enabled .set_value(new_value, ctx)); }); ctx.notify(); } fn toggle_hide_secrets_in_block_list(&mut self, ctx: &mut ViewContext) { let safe_mode_settings = SafeModeSettings::handle(ctx); let new_value = { !*safe_mode_settings .as_ref(ctx) .hide_secrets_in_block_list .value() }; ctx.update_model(&safe_mode_settings, move |safe_mode_settings, ctx| { report_if_error!(safe_mode_settings .hide_secrets_in_block_list .set_value(new_value, ctx)); }); ctx.notify(); } fn set_secret_display_mode(&mut self, mode: SecretDisplayMode, ctx: &mut ViewContext) { let safe_mode_settings = SafeModeSettings::handle(ctx); ctx.update_model(&safe_mode_settings, move |safe_mode_settings, ctx| { report_if_error!(safe_mode_settings.secret_display_mode.set_value(mode, ctx)); }); ctx.notify(); } fn toggle_telemetry(&mut self, ctx: &mut ViewContext) { let privacy_settings_handle = PrivacySettings::handle(ctx); let old_value = privacy_settings_handle.as_ref(ctx).is_telemetry_enabled; ctx.update_model(&privacy_settings_handle, |privacy_settings, ctx| { privacy_settings.set_is_telemetry_enabled(!old_value, ctx); }); ctx.notify(); } fn toggle_crash_reporting(&mut self, ctx: &mut ViewContext) { let privacy_settings_handle = PrivacySettings::handle(ctx); let old_value = privacy_settings_handle .as_ref(ctx) .is_crash_reporting_enabled; ctx.update_model(&privacy_settings_handle, |privacy_settings, ctx| { privacy_settings.set_is_crash_reporting_enabled(!old_value, ctx); }); ctx.notify(); } fn queue_regex_removal(&mut self, idx: usize, ctx: &mut ViewContext) { // Check if this removal is already pending if self.pending_regex_removals.contains(&idx) { return; } if let Some(timer) = self.pending_timer.take() { timer.abort(); } // Add to pending set self.pending_regex_removals.insert(idx); ctx.notify(); // Start a new timer only if we don't have one if self.pending_timer.is_none() { let handle = ctx.spawn( async move { Timer::after(Duration::from_millis(Self::BATCH_TIMEOUT_MS)).await; }, |me, _, ctx| { // Only process if we still have pending removals and a timer // (they might have been processed by an add operation) if !me.pending_regex_removals.is_empty() && me.pending_timer.is_some() { me.pending_timer = None; me.process_pending_removals(ctx); } }, ); self.pending_timer = Some(handle); } } fn update_secret_display_dropdown(&mut self, ctx: &mut ViewContext) { let safe_mode_settings = SafeModeSettings::as_ref(ctx); let current_mode = get_effective_secret_display_mode(safe_mode_settings); self.secret_redaction_display_dropdown .update(ctx, |dropdown, ctx| { dropdown.set_selected_by_action( PrivacyPageAction::SetSecretDisplayMode(current_mode), ctx, ); }); } fn process_pending_removals(&mut self, ctx: &mut ViewContext) { let mut indices: Vec<_> = self.pending_regex_removals.iter().copied().collect(); if indices.is_empty() { return; } indices.sort_unstable_by(|a, b| b.cmp(a)); // Sort in reverse order to remove from highest index first let privacy_settings_handle = PrivacySettings::handle(ctx); for idx in indices { privacy_settings_handle.update(ctx, |privacy_settings, ctx| { privacy_settings.remove_user_secret_regex(&idx, ctx); }); } self.pending_regex_removals.clear(); ctx.notify(); } fn launch_network_logging(&mut self, ctx: &mut ViewContext) { ctx.emit(PrivacyPageViewEvent::LaunchNetworkLogging); } fn show_add_regex_modal(&mut self, ctx: &mut ViewContext) { self.add_regex_modal_state.open(ctx); ctx.emit(PrivacyPageViewEvent::ShowAddRegexModal); } fn hide_add_regex_modal(&mut self, ctx: &mut ViewContext) { self.add_regex_modal_state.close(ctx); ctx.emit(PrivacyPageViewEvent::HideAddRegexModal); } fn handle_modal_event(&mut self, event: &ModalEvent, ctx: &mut ViewContext) { match event { ModalEvent::Close => { self.hide_add_regex_modal(ctx); } } } fn handle_add_regex_modal_event( &mut self, event: &AddRegexModalEvent, ctx: &mut ViewContext, ) { match event { AddRegexModalEvent::Close => { self.hide_add_regex_modal(ctx); } AddRegexModalEvent::Submit { name, pattern } => { self.add_custom_regex(name.clone(), pattern.clone(), ctx); self.hide_add_regex_modal(ctx); } } } fn add_custom_regex(&mut self, name: String, pattern: String, ctx: &mut ViewContext) { // First process any pending removals if !self.pending_regex_removals.is_empty() { self.process_pending_removals(ctx); } let privacy_settings_handle = PrivacySettings::handle(ctx); ctx.update_model(&privacy_settings_handle, |privacy_settings, ctx| { if let Ok(regex) = Regex::new(&pattern) { let mut new_user_secret_regex_list = privacy_settings.user_secret_regex_list.to_vec(); new_user_secret_regex_list.push(CustomSecretRegex { pattern: regex, name: if name.trim().is_empty() { None } else { Some(name.trim().to_string()) }, }); if privacy_settings .user_secret_regex_list .set_value(new_user_secret_regex_list, ctx) .is_err() { log::error!("Failed to add custom regex to secret regex list"); } ctx.notify(); } else { log::error!("Invalid regex pattern: {pattern}"); } }); } pub fn get_modal_content(&self) -> Option> { if self.add_regex_modal_state.is_open() { Some(self.add_regex_modal_state.render()) } else { None } } } impl View for PrivacyPageView { fn ui_name() -> &'static str { "PrivacyPageView" } fn render(&self, app: &AppContext) -> Box { self.page.render(self, app) } } impl Entity for PrivacyPageView { type Event = PrivacyPageViewEvent; } #[derive(Clone, Debug, PartialEq)] pub enum PrivacyPageAction { ToggleSafeMode, ToggleHideSecretsInBlockList, SetSecretDisplayMode(SecretDisplayMode), ToggleTelemetry, ToggleCrashReporting, LaunchNetworkLogging, RemoveCustomRegex(usize), AddAllRecommendedRegexes, ShowAddRegexModal, AddRecommendedRegex(usize), SwitchSecretRedactionTab(SecretRedactionTab), } #[derive(Clone, Copy, Debug, PartialEq)] pub enum SecretRedactionTab { Personal, Enterprise, } impl TypedActionView for PrivacyPageView { type Action = PrivacyPageAction; fn handle_action(&mut self, action: &PrivacyPageAction, ctx: &mut ViewContext) { match action { PrivacyPageAction::AddRecommendedRegex(idx) => { // First process any pending removals if !self.pending_regex_removals.is_empty() { self.process_pending_removals(ctx); } let privacy_settings_handle = PrivacySettings::handle(ctx); ctx.update_model(&privacy_settings_handle, |privacy_settings, ctx| { let workspaces = UserWorkspaces::as_ref(ctx); let enterprise_regex_list = workspaces.get_enterprise_secret_redaction_regex_list(); let current_patterns: Vec<&str> = enterprise_regex_list .iter() .map(|s| s.pattern.as_str()) .chain( privacy_settings .user_secret_regex_list .iter() .map(|r| r.pattern().as_str()), ) .collect(); let recommended_regexes: Vec<_> = crate::terminal::model::secrets::regexes::DEFAULT_REGEXES_WITH_NAMES .iter() .filter(|r| !current_patterns.contains(&r.pattern)) .collect(); if let Some(regex) = recommended_regexes.get(*idx) { if let Ok(pattern) = Regex::new(regex.pattern) { let mut new_user_secret_regex_list = privacy_settings.user_secret_regex_list.to_vec(); new_user_secret_regex_list.push(CustomSecretRegex { pattern, name: Some(regex.name.to_string()), }); if privacy_settings .user_secret_regex_list .set_value(new_user_secret_regex_list, ctx) .is_err() { log::error!( "Failed to add recommended regex to custom secret regex list" ); } ctx.notify(); } } }); } PrivacyPageAction::ToggleSafeMode => self.toggle_safe_mode(ctx), PrivacyPageAction::ToggleHideSecretsInBlockList => { self.toggle_hide_secrets_in_block_list(ctx) } PrivacyPageAction::SetSecretDisplayMode(mode) => { self.set_secret_display_mode(*mode, ctx) } PrivacyPageAction::ToggleTelemetry => self.toggle_telemetry(ctx), PrivacyPageAction::ToggleCrashReporting => self.toggle_crash_reporting(ctx), PrivacyPageAction::LaunchNetworkLogging => self.launch_network_logging(ctx), PrivacyPageAction::RemoveCustomRegex(idx) => { self.queue_regex_removal(*idx, ctx); } PrivacyPageAction::AddAllRecommendedRegexes => { // First process any pending removals if !self.pending_regex_removals.is_empty() { self.process_pending_removals(ctx); } let privacy_settings_handle = PrivacySettings::handle(ctx); ctx.update_model(&privacy_settings_handle, |privacy_settings, ctx| { privacy_settings.add_all_recommended_regex(ctx); }); } PrivacyPageAction::ShowAddRegexModal => { self.show_add_regex_modal(ctx); } PrivacyPageAction::SwitchSecretRedactionTab(tab) => { self.active_secret_redaction_tab = *tab; ctx.notify(); } } } } impl SettingsPageMeta for PrivacyPageView { fn section() -> SettingsSection { SettingsSection::Privacy } fn should_render(&self, _ctx: &AppContext) -> bool { true } fn update_filter(&mut self, query: &str, ctx: &mut ViewContext) -> MatchData { self.page.update_filter(query, ctx) } fn scroll_to_widget(&mut self, widget_id: &'static str) { self.page.scroll_to_widget(widget_id) } fn clear_highlighted_widget(&mut self) { self.page.clear_highlighted_widget(); } } impl From> for SettingsPageViewHandle { fn from(view_handle: ViewHandle) -> Self { SettingsPageViewHandle::Privacy(view_handle) } } #[derive(Default)] struct SecretRedactionWidget { switch_state: SwitchStateHandle, add_regex_button_mouse_state: MouseStateHandle, add_recommended_button_mouse_states: RefCell>, add_all_button_mouse_state: MouseStateHandle, personal_tab_mouse_state: MouseStateHandle, enterprise_tab_mouse_state: MouseStateHandle, } impl SecretRedactionWidget { /// Ensures there's enough mouse states for the recommended regexes to be added. fn ensure_recommended_regex_mouse_states(&self, count: usize) { while self.add_recommended_button_mouse_states.borrow().len() < count { self.add_recommended_button_mouse_states .borrow_mut() .push(Default::default()); } } fn render_tab( &self, label: String, count: usize, tab_type: SecretRedactionTab, is_active: bool, mouse_state: MouseStateHandle, appearance: &Appearance, ) -> Box { let theme = appearance.theme(); let (background_fill, text_color, count_color) = if is_active { ( Some(theme.surface_overlay_1()), theme.active_ui_text_color().into(), theme.sub_text_color(theme.surface_2()).into(), ) } else { ( None, theme.sub_text_color(theme.background()).into(), theme .sub_text_color(theme.background()) .with_opacity(56) .into(), ) }; let hover_background = if !is_active { Some(appearance.theme().surface_overlay_2()) } else { None }; Hoverable::new(mouse_state, move |mouse_state| { let is_hovered = mouse_state.is_hovered(); let tab_content = Flex::row() .with_child( Text::new_inline(label.clone(), appearance.ui_font_family(), FONT_SIZE) .with_color(text_color) .finish(), ) .with_child( Container::new( Text::new_inline( format!(" {count}"), appearance.ui_font_family(), FONT_SIZE, ) .with_color(count_color) .finish(), ) .finish(), ) .finish(); let mut container = Container::new(tab_content) .with_vertical_padding(9.) .with_horizontal_padding(12.) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.))); // Apply background based on hover state if is_hovered && !is_active { if let Some(hover_bg) = hover_background { container = container.with_background(hover_bg); } } else if let Some(bg) = background_fill { container = container.with_background(bg); } container.finish() }) .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(PrivacyPageAction::SwitchSecretRedactionTab(tab_type)); }) .with_cursor(Cursor::PointingHand) .finish() } /// Renders the tab bar for switching between Personal and Enterprise views fn render_tab_bar( &self, appearance: &Appearance, privacy_settings: &PrivacySettings, active_tab: SecretRedactionTab, view: &PrivacyPageView, app: &AppContext, ) -> Box { if !privacy_settings.is_enterprise_secret_redaction_enabled() { return Empty::new().finish(); } let workspaces = UserWorkspaces::as_ref(app); let enterprise_regex_list = workspaces.get_enterprise_secret_redaction_regex_list(); let enterprise_count = enterprise_regex_list.len(); // Count personal regexes excluding pending removals let personal_count = privacy_settings .user_secret_regex_list .iter() .enumerate() .filter(|(i, _)| !view.pending_regex_removals.contains(i)) .count(); let personal_tab = self.render_tab( "Personal".to_string(), personal_count, SecretRedactionTab::Personal, active_tab == SecretRedactionTab::Personal, self.personal_tab_mouse_state.clone(), appearance, ); let is_enterprise_tab_active = active_tab == SecretRedactionTab::Enterprise; let enterprise_tab = self.render_tab( "Enterprise".to_string(), enterprise_count, SecretRedactionTab::Enterprise, is_enterprise_tab_active, self.enterprise_tab_mouse_state.clone(), appearance, ); let mut row = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child(personal_tab) .with_child(enterprise_tab); if is_enterprise_tab_active { row.add_child(Shrinkable::new(1., Empty::new().finish()).finish()); row.add_child(self.render_info( "Enterprise secret redaction cannot be modified.".to_string(), appearance, )); } Container::new(row.finish()) .with_margin_bottom(16.) .finish() } /// Renders a section title with consistent styling fn render_section_title(&self, title: String, appearance: &Appearance) -> Box { Text::new_inline(title, appearance.ui_font_family(), FONT_SIZE) .with_color(appearance.theme().active_ui_text_color().into()) .finish() } /// Renders a description paragraph with consistent styling fn render_description( &self, text: String, appearance: &Appearance, margin_bottom: f32, ) -> Box { let description_text_color = description_text_color(appearance.theme()).into_solid(); appearance .ui_builder() .paragraph(text) .with_style(UiComponentStyles { font_color: Some(description_text_color), margin: Some( Coords::default() .top(styles::DESCRIPTION_LINE_MARGIN_BOTTOM) .bottom(margin_bottom), ), ..Default::default() }) .build() .finish() } /// Renders a regex item with consistent container styling fn render_regex_item( &self, content: Box, appearance: &Appearance, ) -> Box { let background = appearance.theme().surface_overlay_1(); Container::new(content) .with_background(background) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.))) .with_uniform_padding(8.) .with_margin_bottom(4.) .finish() } fn horizontal_divider(&self, appearance: &Appearance) -> Box { Container::new( ConstrainedBox::new( Rect::new() .with_background(appearance.theme().outline()) .finish(), ) .with_height(1.) .finish(), ) .with_vertical_margin(24.) .finish() } /// Renders regex content using the RegexDisplayInfo trait (supports both user and enterprise regexes) fn render_regex_content( &self, regex_info: &T, appearance: &Appearance, ) -> Box { let regex_color = internal_colors::fg_overlay_6(appearance.theme()); if let Some(name) = regex_info.name() { Flex::column() .with_child( Text::new_inline(name.to_string(), appearance.ui_font_family(), FONT_SIZE) .with_color(appearance.theme().active_ui_text_color().into()) .finish(), ) .with_child( Text::new_inline( regex_info.pattern().to_string(), appearance.ui_font_family(), FONT_SIZE, ) .with_color(regex_color.into()) .finish(), ) .finish() } else { Text::new_inline( regex_info.pattern().to_string(), appearance.ui_font_family(), FONT_SIZE, ) .with_color(regex_color.into()) .finish() } } /// Renders the enterprise tab content (regexes with title support) fn render_enterprise_content( &self, appearance: &Appearance, app: &AppContext, ) -> Box { let workspaces = UserWorkspaces::as_ref(app); let enterprise_regex_list = workspaces.get_enterprise_secret_redaction_regex_list(); let ui_builder = appearance.ui_builder(); let description_text_color = description_text_color(appearance.theme()).into_solid(); if enterprise_regex_list.is_empty() { return ui_builder .paragraph("No enterprise regexes have been configured by your organization.") .with_style(UiComponentStyles { font_color: Some(description_text_color), ..Default::default() }) .build() .finish(); } let mut column = Flex::column(); for enterprise_regex in enterprise_regex_list { let content = self.render_regex_content(&enterprise_regex, appearance); let item = self.render_regex_item( Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child(Expanded::new(1., content).finish()) .finish(), appearance, ); column.add_child(item); } column.finish() } /// Renders the personal tab content (user regexes + recommended regexes) fn render_personal_content( &self, view: &PrivacyPageView, appearance: &Appearance, app: &AppContext, ) -> Box { let privacy_settings = PrivacySettings::as_ref(app); let ui_builder = appearance.ui_builder(); let workspaces = UserWorkspaces::as_ref(app); let mut column = Flex::column(); for (i, regex) in privacy_settings.user_secret_regex_list.iter().enumerate() { if view.pending_regex_removals.contains(&i) { continue; } let text_content = self.render_regex_content(regex, appearance); let item = self.render_regex_item( Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child(Expanded::new(1., text_content).finish()) .with_child( ui_builder .close_button( 20., // diameter view.added_user_secret_regex_list_button_handles[i].clone(), ) .build() .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(PrivacyPageAction::RemoveCustomRegex(i)); }) .finish(), ) .finish(), appearance, ); column.add_child(item); } // Get a list of regexes that are recommended but not currently in use let enterprise_regex_list_with_titles = workspaces.get_enterprise_secret_redaction_regex_list(); let current_patterns: Vec<&str> = enterprise_regex_list_with_titles .iter() .map(|r| r.pattern.as_str()) .chain( privacy_settings .user_secret_regex_list .iter() .map(|r| r.pattern().as_str()), ) .collect(); let recommended_regexes: Vec<_> = crate::terminal::model::secrets::regexes::DEFAULT_REGEXES_WITH_NAMES .iter() .filter(|r| !current_patterns.contains(&r.pattern)) .collect(); if !recommended_regexes.is_empty() { column.add_child(self.horizontal_divider(appearance)); // Add the "Recommended" header with "Add all" button column.add_child( Container::new( Flex::row() .with_main_axis_size(MainAxisSize::Max) .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child( self.render_section_title("Recommended".to_string(), appearance), ) .with_child( Container::new( ui_builder .button( ButtonVariant::Secondary, self.add_all_button_mouse_state.clone(), ) .with_text_and_icon_label(Self::add_button( "Add all", appearance, )) .with_style(Self::add_button_style()) .build() .on_click(move |ctx, _, _| { ctx.dispatch_typed_action( PrivacyPageAction::AddAllRecommendedRegexes, ); }) .finish(), ) .with_margin_bottom(8.) .finish(), ) .finish(), ) .finish(), ); self.ensure_recommended_regex_mouse_states(recommended_regexes.len()); let recommended_button_states = self.add_recommended_button_mouse_states.borrow(); for (i, regex) in recommended_regexes.iter().enumerate() { let text_content = self.render_regex_content(regex, appearance); let item = self.render_regex_item( Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child(Expanded::new(1., text_content).finish()) .with_child( icon_button( appearance, Icon::Plus, false, recommended_button_states[i].clone(), ) .build() .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(PrivacyPageAction::AddRecommendedRegex( i, )); }) .finish(), ) .finish(), appearance, ); column.add_child(item); } } column.finish() } fn render_info(&self, text: String, appearance: &Appearance) -> Box { let info_icon = Container::new( ConstrainedBox::new( Icon::Info .to_galaxyui_icon( appearance .theme() .hint_text_color(appearance.theme().background()), ) .finish(), ) .with_width(appearance.ui_font_size() * 1.2) .with_height(appearance.ui_font_size() * 1.2) .finish(), ) .with_padding_right(4.) .finish(); Flex::row() .with_child(info_icon) .with_child( appearance .ui_builder() .span(text) .with_style(UiComponentStyles { font_color: Some( appearance .theme() .hint_text_color(appearance.theme().background()) .into_solid(), ), font_size: Some(FONT_SIZE), ..Default::default() }) .build() .finish(), ) .finish() } fn add_button(text: impl Into>, appearance: &Appearance) -> TextAndIcon { TextAndIcon::new( TextAndIconAlignment::IconFirst, text, Icon::Plus.to_galaxyui_icon(appearance.theme().active_ui_text_color()), MainAxisSize::Min, MainAxisAlignment::SpaceBetween, vec2f(16., 16.), ) .with_inner_padding(3.) } fn add_button_style() -> UiComponentStyles { UiComponentStyles { padding: Some(Coords { // There's some offset issue with the button component left: 8., right: 12., top: 6., bottom: 6., }), margin: Some(Coords { left: 8., right: 0., top: 0., bottom: 0., }), ..Default::default() } } } impl SettingsWidget for SecretRedactionWidget { type View = PrivacyPageView; fn search_terms(&self) -> &str { "secret redaction safe mode hide" } fn render( &self, view: &Self::View, appearance: &Appearance, app: &AppContext, ) -> Box { let safe_mode_settings = SafeModeSettings::as_ref(app); let privacy_settings = PrivacySettings::as_ref(app); let description_text_color = description_text_color(appearance.theme()).into_solid(); let ui_builder = appearance.ui_builder(); let is_enterprise_enabled = privacy_settings.is_enterprise_secret_redaction_enabled(); let local_only_icon_state = LocalOnlyIconState::for_setting( SafeModeEnabled::storage_key(), SafeModeEnabled::sync_to_cloud(), &mut view.local_only_icon_tooltip_states.borrow_mut(), app, ); let secret_redaction_title_row = Container::new( Flex::row() .with_child( Shrinkable::new( 1.0, render_sub_header(appearance, SAFE_MODE_TITLE, Some(local_only_icon_state)), ) .finish(), ) .with_child( Container::new({ if is_enterprise_enabled { self.render_info( "Enabled by your organization.".to_string(), appearance, ) } else { ui_builder .switch(self.switch_state.clone()) .check(*safe_mode_settings.safe_mode_enabled.value()) .build() .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(PrivacyPageAction::ToggleSafeMode) }) .finish() } }) .with_padding_right(TOGGLE_BUTTON_RIGHT_PADDING) .finish(), ) .with_cross_axis_alignment(CrossAxisAlignment::Start) .finish(), ) .with_padding_bottom(HEADER_PADDING) .finish(); let mut column = Flex::column() .with_child(secret_redaction_title_row) .with_child( ui_builder .paragraph((*SAFE_MODE_DESCRIPTION).to_owned()) .with_style(UiComponentStyles { font_color: Some(description_text_color), font_size: Some(FONT_SIZE + 1.), // One size up from current 12px to 13px margin: Some( Coords::default() .top(-24.) .bottom(styles::DESCRIPTION_LINE_MARGIN_BOTTOM), ), ..Default::default() }) .build() .finish(), ); if *safe_mode_settings.safe_mode_enabled { // Add the secret display mode dropdown let local_only_icon_state = LocalOnlyIconState::for_setting( SecretDisplayModeSetting::storage_key(), SecretDisplayModeSetting::sync_to_cloud(), &mut view.local_only_icon_tooltip_states.borrow_mut(), app, ); // Create the label with local-only icon if needed let label_with_icon = super::settings_page::render_dropdown_item_label( "Secret visual redaction mode".to_string(), None, local_only_icon_state, None, appearance, ); // Create left column with label and description let left_content = Flex::column() .with_child(label_with_icon) .with_child( Container::new( ui_builder .paragraph( "Choose how secrets are visually presented in the block list while keeping them searchable. This setting only affects what you see in the block list.", ) .with_style(UiComponentStyles { font_color: Some(description_text_color), margin: Some( Coords::default() .top(4.) .bottom(0.), ), ..Default::default() }) .build() .finish() ) .finish() ) .finish(); // Create the horizontal row with left content and dropdown let dropdown_row = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Start) .with_child( Shrinkable::new( 1.0, Container::new(left_content) .with_padding_right(16.) // Space between left content and dropdown .finish(), ) .finish(), ) .with_child(ChildView::new(&view.secret_redaction_display_dropdown).finish()) .finish(); column.add_child( Container::new(dropdown_row) .with_margin_top(8.) .with_margin_bottom(styles::DESCRIPTION_MARGIN_BOTTOM) .finish(), ); // User regexes section column.add_child( Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Start) .with_child( Expanded::new( 1., Flex::column() .with_child(self.render_section_title( USER_SECRET_REGEX_TITLE.to_string(), appearance, )) .with_child(self.render_description( USER_SECRET_REGEX_DESCRIPTION.to_owned(), appearance, if privacy_settings.user_secret_regex_list.iter().count() > 0 { 10. } else { 0. }, )) .finish(), ) .finish(), ) .with_child( ui_builder .button( ButtonVariant::Secondary, self.add_regex_button_mouse_state.clone(), ) .with_text_and_icon_label(Self::add_button("Add regex", appearance)) .with_style(Self::add_button_style()) .build() .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(PrivacyPageAction::ShowAddRegexModal); }) .finish(), ) .finish(), ); let workspaces = UserWorkspaces::as_ref(app); let enterprise_regex_list = workspaces.get_enterprise_secret_redaction_regex_list(); if is_enterprise_enabled && !enterprise_regex_list.is_empty() { column.add_child(self.render_tab_bar( appearance, privacy_settings, view.active_secret_redaction_tab, view, app, )); } let tab_content = if is_enterprise_enabled && !enterprise_regex_list.is_empty() { match view.active_secret_redaction_tab { SecretRedactionTab::Personal => { self.render_personal_content(view, appearance, app) } SecretRedactionTab::Enterprise => { self.render_enterprise_content(appearance, app) } } } else { self.render_personal_content(view, appearance, app) }; column.add_child(tab_content); column.add_child(self.horizontal_divider(appearance)); } Container::new(column.finish()) .with_padding_top(PAGE_PADDING) .finish() } } #[derive(Default)] struct AppAnalyticsWidget { switch_state: SwitchStateHandle, docs_link_mouse_state: MouseStateHandle, zdr_badge_mouse_state: MouseStateHandle, } impl AppAnalyticsWidget { fn render_zero_data_retention_badge(&self, appearance: &Appearance) -> Box { let ui_builder = appearance.ui_builder(); Hoverable::new(self.zdr_badge_mouse_state.clone(), move |mouse_state| { let is_hovered = mouse_state.is_hovered(); let theme = appearance.theme(); let background_color = appearance.theme().accent(); let badge = Container::new( Text::new_inline("ZDR", appearance.ui_font_family(), CONTENT_FONT_SIZE - 2.) .with_color(theme.active_ui_text_color().into()) .finish(), ) .with_background(background_color) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(3.))) .with_uniform_padding(3.) .with_margin_left(8.) .finish(); let mut stack = Stack::new().with_child(badge); if is_hovered { let tooltip = ui_builder.tool_tip( "Your administrator has enabled zero data retention for your team. User generated content will never be collected." .to_string(), ); stack.add_positioned_child( tooltip.build().finish(), OffsetPositioning::offset_from_parent( vec2f(0., -3.), ParentOffsetBounds::Unbounded, ParentAnchor::TopLeft, ChildAnchor::BottomLeft, ), ); } stack.finish() }) .with_cursor(Cursor::PointingHand) .finish() } fn should_show_zdr_badge(&self, app: &AppContext) -> bool { let setting = UserWorkspaces::as_ref(app).get_ugc_collection_enablement_setting(); matches!(setting, UgcCollectionEnablementSetting::Disable) } } impl SettingsWidget for AppAnalyticsWidget { type View = PrivacyPageView; fn search_terms(&self) -> &str { "telemetry usage analytics data collection" } fn should_render(&self, app: &AppContext) -> bool { // Builds without a telemetry config (e.g. OpenWarp) cannot ship // telemetry, so the toggle would be a no-op. Hide it in that case. if !ChannelState::is_telemetry_available() { return false; } let privacy_settings = PrivacySettings::as_ref(app); !privacy_settings.is_telemetry_force_enabled() } fn render( &self, _view: &Self::View, appearance: &Appearance, app: &AppContext, ) -> Box { let privacy_settings = PrivacySettings::as_ref(app); let ui_builder = appearance.ui_builder(); let description_text_color = description_text_color(appearance.theme()).into_solid(); let is_enterprise = UserWorkspaces::as_ref(app) .current_workspace() .is_some_and(|w| w.billing_metadata.customer_type == CustomerType::Enterprise); // Keep the old description for enterprise users because we do not collect block input/output for them. let description = if is_enterprise { TELEMETRY_DESCRIPTION_OLD } else { TELEMETRY_DESCRIPTION }; let org_setting = UserWorkspaces::handle(app) .as_ref(app) .get_ugc_collection_enablement_setting(); let (is_toggleable, is_checked) = match org_setting { UgcCollectionEnablementSetting::Enable => (false, true), UgcCollectionEnablementSetting::Disable => (false, false), UgcCollectionEnablementSetting::RespectUserSetting => { (true, privacy_settings.is_telemetry_enabled) } }; let zdr_label_component = if self.should_show_zdr_badge(app) { Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child(render_body_item_label::( TELEMETRY_TITLE.into(), None, None, LocalOnlyIconState::Hidden, is_toggleable.into(), appearance, )) .with_child(self.render_zero_data_retention_badge(appearance)) .finish() } else { render_body_item_label::( TELEMETRY_TITLE.into(), None, None, LocalOnlyIconState::Hidden, is_toggleable.into(), appearance, ) }; let switch = ui_builder .switch(self.switch_state.clone()) .check(is_checked); let switch = if is_toggleable { switch .build() .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(PrivacyPageAction::ToggleTelemetry) }) .finish() } else { switch .with_tooltip(TooltipConfig { text: "This setting is managed by your organization.".to_string(), styles: ui_builder.default_tool_tip_styles(), }) .disable() .build() .finish() }; // Check if user is on free tier to show the AI requirement note // Fail safe: if billing status is unknown, assume paid (don't show free tier note) let is_on_paid_plan = UserWorkspaces::as_ref(app) .current_workspace() .map(|w| w.billing_metadata.is_user_on_paid_plan()) .unwrap_or(true); let mut column = Flex::column(); column.add_child(super::settings_page::build_toggle_element( zdr_label_component, switch, appearance, None, )); column.add_child( ui_builder .paragraph(description) .with_style(UiComponentStyles { font_color: Some(description_text_color), margin: Some( Coords::default() .top(styles::DESCRIPTION_NEGATIVE_MARGIN_OFFSET) .bottom(styles::DESCRIPTION_LINE_MARGIN_BOTTOM), ), ..Default::default() }) .build() .finish(), ); // Show free tier note only for non-paid users if !is_on_paid_plan { column.add_child( ui_builder .paragraph(TELEMETRY_FREE_TIER_NOTE) .with_style(UiComponentStyles { font_color: Some(description_text_color), margin: Some( Coords::default().bottom(styles::DESCRIPTION_LINE_MARGIN_BOTTOM), ), ..Default::default() }) .build() .finish(), ); } column.add_child( Align::new( ui_builder .link( "Read more about Warp's use of data".into(), Some(TELEMETRY_DOCS_URL.into()), None, self.docs_link_mouse_state.clone(), ) .soft_wrap(false) .build() .with_margin_bottom(styles::DESCRIPTION_MARGIN_BOTTOM) .finish(), ) .left() .finish(), ); column.finish() } } #[derive(Default)] struct CrashReportsWidget { switch_state: SwitchStateHandle, } impl SettingsWidget for CrashReportsWidget { type View = PrivacyPageView; fn search_terms(&self) -> &str { "telemetry crash reports stability data collection" } fn should_render(&self, app: &AppContext) -> bool { // Builds without a crash reporting config (e.g. OpenWarp) cannot ship // crash reports, so the toggle would be a no-op. Hide it in that case. if !ChannelState::is_crash_reporting_available() { return false; } let privacy_settings = PrivacySettings::as_ref(app); !privacy_settings.is_telemetry_force_enabled() } fn render( &self, _view: &Self::View, appearance: &Appearance, app: &AppContext, ) -> Box { let ui_builder = appearance.ui_builder(); let privacy_settings = PrivacySettings::as_ref(app); Flex::column() .with_child(render_body_item::( "Send crash reports".into(), None, // Crash report state is always synced to cloud, so no need to show local only icon. LocalOnlyIconState::Hidden, ToggleState::Enabled, appearance, ui_builder .switch(self.switch_state.clone()) .check(privacy_settings.is_crash_reporting_enabled) .build() .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(PrivacyPageAction::ToggleCrashReporting) }) .finish(), None, )) .with_child( ui_builder .paragraph( "Crash reports assist with debugging and stability improvements." .to_owned(), ) .with_style(UiComponentStyles { font_color: Some( appearance .theme() .sub_text_color(appearance.theme().surface_2()) .into_solid(), ), margin: Some( Coords::default() .top(styles::DESCRIPTION_NEGATIVE_MARGIN_OFFSET) .bottom(styles::DESCRIPTION_MARGIN_BOTTOM), ), ..Default::default() }) .build() .finish(), ) .finish() } } #[derive(Default)] struct NetworkLogWidget { link_mouse_state: MouseStateHandle, } impl SettingsWidget for NetworkLogWidget { type View = PrivacyPageView; fn search_terms(&self) -> &str { "network log audit console data collection" } fn render( &self, _view: &Self::View, appearance: &Appearance, _app: &AppContext, ) -> Box { let ui_builder = appearance.ui_builder(); Flex::column() .with_child(render_body_item::( "Network log console".into(), None, // Not rendering a setting, so no need to show local only icon state. LocalOnlyIconState::Hidden, ToggleState::Enabled, appearance, Empty::new().finish(), None, )) .with_child( ui_builder .paragraph( "We've built a native console that allows you to view all communications \ from Warp to external servers to ensure you feel comfortable that your \ work is always kept safe." .to_owned(), ) .with_style(UiComponentStyles { font_color: Some( appearance .theme() .sub_text_color(appearance.theme().surface_2()) .into_solid(), ), margin: Some( Coords::default() .top(styles::DESCRIPTION_NEGATIVE_MARGIN_OFFSET) .bottom(styles::DESCRIPTION_LINE_MARGIN_BOTTOM), ), ..Default::default() }) .build() .finish(), ) .with_child( Align::new( ui_builder .link( "View network logging".to_owned(), None, Some(Box::new(|ctx| { ctx.dispatch_typed_action(PrivacyPageAction::LaunchNetworkLogging); })), self.link_mouse_state.clone(), ) .soft_wrap(false) .build() .with_margin_bottom(styles::DESCRIPTION_MARGIN_BOTTOM) .finish(), ) .left() .finish(), ) .finish() } } pub fn init_actions_from_parent_view( app: &mut AppContext, context: &ContextPredicate, builder: fn(SettingsAction) -> T, ) { let mut toggle_binding_pairs = vec![ ToggleSettingActionPair::new( "app analytics", builder(SettingsAction::PrivacyPageToggle( PrivacyPageAction::ToggleTelemetry, )), context, flags::TELEMETRY_FLAG, ), ToggleSettingActionPair::new( "crash reporting", builder(SettingsAction::PrivacyPageToggle( PrivacyPageAction::ToggleCrashReporting, )), context, flags::CRASH_REPORTING_FLAG, ), ]; toggle_binding_pairs.push(ToggleSettingActionPair::new( "secret redaction", builder(SettingsAction::PrivacyPageToggle( PrivacyPageAction::ToggleSafeMode, )), context, flags::SAFE_MODE_FLAG, )); ToggleSettingActionPair::add_toggle_setting_action_pairs_as_bindings(toggle_binding_pairs, app); } mod styles { // Apply a negative margin to the description text so it appears closer to the main // settings option text. pub const DESCRIPTION_NEGATIVE_MARGIN_OFFSET: f32 = -8.; /// The space between a description and the next toggle. pub const DESCRIPTION_MARGIN_BOTTOM: f32 = 12.; /// The space between two description lines which are describing the same toggle. pub const DESCRIPTION_LINE_MARGIN_BOTTOM: f32 = 6.; } fn description_text_color(theme: &WarpTheme) -> galaxy_core::ui::theme::Fill { theme.sub_text_color(theme.surface_2()) }