diff --git a/app/src/context_chips/display_chip.rs b/app/src/context_chips/display_chip.rs index e03f4287e77..7ea03998572 100644 --- a/app/src/context_chips/display_chip.rs +++ b/app/src/context_chips/display_chip.rs @@ -51,10 +51,12 @@ use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel; use crate::terminal::input::{MenuPositioning, MenuPositioningProvider}; use crate::terminal::model::session::SessionType; use crate::terminal::model_events::ModelEventDispatcher; +use crate::terminal::settings::{TerminalSettings, TerminalSettingsChangedEvent}; use crate::terminal::view::ambient_agent::AmbientAgentViewModel; use crate::ui_components::blended_colors; use crate::ui_components::icons::Icon; use crate::util::bindings::keybinding_name_to_display_string; +use crate::util::git_branch_display::format_git_branch_name; use crate::util::truncation::truncate_from_beginning; use crate::view_components::action_button::{ActionButtonTheme, NakedTheme}; use crate::view_components::{FeaturePopup, NewFeaturePopupEvent, NewFeaturePopupLabel}; @@ -1129,6 +1131,22 @@ impl DisplayChip { } }); + if matches!( + chip_result.kind, + ContextChipKind::ShellGitBranch | ContextChipKind::GitBranchStatus + ) { + ctx.subscribe_to_model(&TerminalSettings::handle(ctx), |_, _, event, ctx| { + if matches!( + event, + TerminalSettingsChangedEvent::GitBranchNameTruncationSetting { .. } + | TerminalSettingsChangedEvent::GitBranchNamePrefixChars { .. } + | TerminalSettingsChangedEvent::GitBranchNameSuffixChars { .. } + ) { + ctx.notify(); + } + }); + } + // Subscribe to ambient agent model changes to re-render when the state changes if let Some(ref ambient_agent_model) = config.ambient_agent_view_model { ctx.subscribe_to_model(ambient_agent_model, |_, _, _, ctx| { @@ -1388,11 +1406,18 @@ impl DisplayChip { let is_interactive = !self.is_shared_session_viewer && !self.is_cli_agent_session_active(app); let is_in_agent_view = self.is_in_agent_view; - let chip_text = self.text.clone(); + let full_branch = self.text.clone(); + let displayed_branch = git_branch_chip_label(&full_branch, app); + let tooltip = if full_branch == displayed_branch { + "Change git branch".to_string() + } else { + format!("Change git branch ({full_branch})") + }; let hover = Hoverable::new(self.mouse_state.clone(), move |state| { let hovered = state.is_hovered() && is_interactive; let mut config = - UdiChipConfig::new_with_icon(Icon::GitBranch, font_color, chip_text.clone()) + UdiChipConfig::new_with_icon(Icon::GitBranch, font_color, displayed_branch.clone()) + .with_truncate_text(false) .with_hovered(hovered); if is_in_agent_view { config = config.for_agent_view(); @@ -1403,7 +1428,7 @@ impl DisplayChip { if state.is_hovered() && is_interactive && !menu_open { let tool_tip = appearance .ui_builder() - .tool_tip("Change git branch".to_string()) + .tool_tip(tooltip.clone()) .build() .finish(); stack.add_positioned_overlay_child(tool_tip, udi_tooltip_positioning()); @@ -1513,20 +1538,22 @@ impl DisplayChip { let font_size = udi_font_size(appearance); let is_interactive = !self.is_shared_session_viewer && !self.is_cli_agent_session_active(app); - let fallback_branch = self.text.clone(); let tracking_status = tracking_status .clone() .or_else(|| GitBranchTrackingStatus::from_display_text(&self.text)); - let tooltip_text = tracking_status + let raw_branch = tracking_status .as_ref() - .map(GitBranchTrackingStatus::tooltip_text); + .map(|status| status.branch.clone()) + .unwrap_or_else(|| self.text.clone()); + let displayed_branch = git_branch_chip_label(&raw_branch, app); + let tooltip_text = match tracking_status.as_ref() { + Some(status) if displayed_branch == raw_branch => Some(status.tooltip_text()), + Some(status) => Some(format!("{} ({raw_branch})", status.tooltip_text())), + None if displayed_branch != raw_branch => Some(raw_branch.clone()), + None => None, + }; let hover = Hoverable::new(self.mouse_state.clone(), move |state| { - let branch = tracking_status - .as_ref() - .map(|status| status.branch.clone()) - .unwrap_or_else(|| fallback_branch.clone()); - let mut content = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center); add_git_branch_status_child( &mut content, @@ -1537,7 +1564,13 @@ impl DisplayChip { ); add_git_branch_status_child( &mut content, - git_branch_status_text(branch, font_color, font_family, font_size, appearance), + git_branch_status_text( + displayed_branch.clone(), + font_color, + font_family, + font_size, + appearance, + ), GIT_BRANCH_STATUS_MAIN_GAP, ); @@ -2352,6 +2385,17 @@ pub(crate) fn chip_container( .with_horizontal_padding(spacing::UDI_CHIP_HORIZONTAL_PADDING) } +fn git_branch_chip_label(branch: &str, app: &AppContext) -> String { + let settings = TerminalSettings::as_ref(app); + format_git_branch_name( + branch, + *settings.git_branch_name_truncation, + *settings.git_branch_name_prefix_chars, + *settings.git_branch_name_suffix_chars, + UDI_CHIP_MAX_NUM_CHARACTERS, + ) +} + pub(crate) fn render_udi_chip(config: UdiChipConfig, appearance: &Appearance) -> Box { let font_size = udi_font_size(appearance); let icon_size = font_size; diff --git a/app/src/settings_view/features_page.rs b/app/src/settings_view/features_page.rs index 1ca19482b78..55473de365c 100644 --- a/app/src/settings_view/features_page.rs +++ b/app/src/settings_view/features_page.rs @@ -45,8 +45,8 @@ use super::settings_page::{ render_dropdown_item_label, render_local_only_icon, }; use super::{ - DisplayCount, SettingsAction, SettingsSection, ToggleSettingActionPair, features, flags, - render_beta_chip, + DisplayCount, SettingActionPairContexts, SettingActionPairDescriptions, SettingsAction, + SettingsSection, ToggleSettingActionPair, features, flags, render_beta_chip, }; use crate::appearance::Appearance; use crate::default_terminal::DefaultTerminal; @@ -99,8 +99,10 @@ use crate::terminal::session_settings::{ SessionSettingsChangedEvent, ShouldConfirmCloseSession, }; use crate::terminal::settings::{ - AsyncFindEnabled, MaximumGridSize, Osc52ClipboardAccess, Osc52ClipboardAccessSetting, - ShowTerminalZeroStateBlock, TerminalSettings, TerminalSettingsChangedEvent, UseAudibleBell, + AsyncFindEnabled, GitBranchNamePrefixChars, GitBranchNameTruncation, + GitBranchNameTruncationSetting, MaximumGridSize, Osc52ClipboardAccess, + Osc52ClipboardAccessSetting, ShowTerminalZeroStateBlock, TerminalSettings, + TerminalSettingsChangedEvent, UseAudibleBell, }; use crate::terminal::{BlockListSettings, PreserveInputFocusOnBlockSelection, SnackbarEnabled}; use crate::undo_close::UndoCloseSettings; @@ -108,6 +110,7 @@ use crate::user_config::{WarpConfig, WarpConfigUpdateEvent}; use crate::util::bindings::{ keybinding_name_to_display_string, reset_keybinding_to_default, set_custom_keybinding, }; +use crate::util::git_branch_display::GIT_BRANCH_NAME_EDGE_CHARS_LIMIT; use crate::view_components::{Dropdown, DropdownItem, FilterableDropdown}; use crate::workspace::WorkspaceAction; use crate::workspace::tab_settings::{NewTabPlacement, TabSettings, TabSettingsChangedEvent}; @@ -296,6 +299,20 @@ pub fn init_actions_from_parent_view( .use_audible_bell .is_supported_on_current_platform(), ), + ToggleSettingActionPair::custom( + SettingActionPairDescriptions::new( + "Show the start of long git branch names", + "Show the end of long git branch names", + ), + builder(SettingsAction::FeaturesPageToggle( + FeaturesPageAction::ToggleGitBranchNameTruncation, + )), + SettingActionPairContexts::new( + context.clone() & !id!(flags::GIT_BRANCH_NAME_SHOW_START), + context.clone() & id!(flags::GIT_BRANCH_NAME_SHOW_START), + ), + None, + ), ToggleSettingActionPair::new( "autosuggestions", builder(SettingsAction::FeaturesPageToggle( @@ -815,6 +832,8 @@ pub enum FeaturesPageAction { SetPreferredGraphicsBackend(Option), SetNewTabPlacement(NewTabPlacement), SetOsc52ClipboardAccess(Osc52ClipboardAccess), + SetGitBranchNameTruncation(GitBranchNameTruncation), + ToggleGitBranchNameTruncation, SetDefaultSessionMode(DefaultSessionMode), SetDefaultTabConfig(String), SearchForKeybinding(String), @@ -859,6 +878,7 @@ const NOTIFICATIONS_DOCS_URL: &str = "https://docs.warp.dev/terminal/more-featur const QUAKE_DROPDOWN_WIDTH: f32 = 130.; const MAX_BLOCK_SIZE_INPUT_BOX_WIDTH: f32 = 80.; +const GIT_BRANCH_EDGE_CHARS_INPUT_WIDTH: f32 = 48.; const MIN_MAX_GRID_SIZE: usize = 100; @@ -1242,6 +1262,17 @@ impl FeaturesPageAction { action: "SetOsc52ClipboardAccess".to_string(), value: format!("{access:?}"), }, + Self::SetGitBranchNameTruncation(truncation) => TelemetryEvent::FeaturesPageAction { + action: "SetGitBranchNameTruncation".to_string(), + value: format!("{truncation:?}"), + }, + Self::ToggleGitBranchNameTruncation => TelemetryEvent::FeaturesPageAction { + action: "ToggleGitBranchNameTruncation".to_string(), + value: format!( + "{:?}", + *TerminalSettings::as_ref(ctx).git_branch_name_truncation + ), + }, Self::SetDefaultSessionMode(mode) => TelemetryEvent::FeaturesPageAction { action: "SetDefaultSessionMode".to_string(), value: format!("{mode:?}"), @@ -1448,6 +1479,11 @@ pub struct FeaturesPageView { graphics_backend_dropdown: ViewHandle>, new_tab_placement_dropdown: ViewHandle>, osc52_clipboard_access_dropdown: ViewHandle>, + git_branch_name_truncation_dropdown: ViewHandle>, + git_branch_name_prefix_chars_editor: ViewHandle, + git_branch_name_suffix_chars_editor: ViewHandle, + git_branch_prefix_chars_valid: bool, + git_branch_suffix_chars_valid: bool, default_session_mode_dropdown: ViewHandle>, tab_behavior: Tracked, completions_keystroke: Tracked, @@ -2025,6 +2061,25 @@ impl TypedActionView for FeaturesPageView { ); }); } + SetGitBranchNameTruncation(truncation) => { + TerminalSettings::handle(ctx).update(ctx, |terminal_settings, ctx| { + report_if_error!( + terminal_settings + .git_branch_name_truncation + .set_value(*truncation, ctx) + ); + }); + } + ToggleGitBranchNameTruncation => { + TerminalSettings::handle(ctx).update(ctx, |terminal_settings, ctx| { + let next = terminal_settings.git_branch_name_truncation.toggled(); + report_if_error!( + terminal_settings + .git_branch_name_truncation + .set_value(next, ctx) + ); + }); + } SetDefaultSessionMode(mode) => self.set_default_session_mode(mode, ctx), SetDefaultTabConfig(path) => { AISettings::handle(ctx).update(ctx, |ai_settings, ctx| { @@ -2386,6 +2441,39 @@ impl FeaturesPageView { ctx, ); } + if matches!( + event, + TerminalSettingsChangedEvent::GitBranchNameTruncationSetting { .. } + ) { + Self::update_git_branch_name_truncation_dropdown( + me.git_branch_name_truncation_dropdown.clone(), + ctx, + ); + } + if matches!( + event, + TerminalSettingsChangedEvent::GitBranchNamePrefixChars { .. } + ) { + let saved = *terminal_settings.as_ref(ctx).git_branch_name_prefix_chars; + me.git_branch_prefix_chars_valid = true; + me.sync_git_branch_edge_chars_editor( + me.git_branch_name_prefix_chars_editor.clone(), + saved, + ctx, + ); + } + if matches!( + event, + TerminalSettingsChangedEvent::GitBranchNameSuffixChars { .. } + ) { + let saved = *terminal_settings.as_ref(ctx).git_branch_name_suffix_chars; + me.git_branch_suffix_chars_valid = true; + me.sync_git_branch_edge_chars_editor( + me.git_branch_name_suffix_chars_editor.clone(), + saved, + ctx, + ); + } ctx.notify() }, ); @@ -2469,6 +2557,12 @@ impl FeaturesPageView { let osc52_clipboard_access_dropdown = ctx.add_typed_action_view(Dropdown::new); Self::update_osc52_clipboard_access_dropdown(osc52_clipboard_access_dropdown.clone(), ctx); + let git_branch_name_truncation_dropdown = ctx.add_typed_action_view(Dropdown::new); + Self::update_git_branch_name_truncation_dropdown( + git_branch_name_truncation_dropdown.clone(), + ctx, + ); + ctx.subscribe_to_model(&TabSettings::handle(ctx), |me, _, event, ctx| { if matches!(event, TabSettingsChangedEvent::NewTabPlacement { .. }) { Self::update_new_tab_placement_dropdown(me.new_tab_placement_dropdown.clone(), ctx); @@ -2632,6 +2726,38 @@ impl FeaturesPageView { me.handle_block_size_editor_event(event, ctx); }); + let git_branch_name_prefix_chars_editor = ctx.add_typed_action_view(|ctx| { + EditorView::single_line(width_and_height_editor_options.clone(), ctx) + }); + git_branch_name_prefix_chars_editor.update(ctx, |editor, ctx| { + editor.set_buffer_text( + &format!( + "{}", + *TerminalSettings::as_ref(ctx).git_branch_name_prefix_chars + ), + ctx, + ); + }); + ctx.subscribe_to_view(&git_branch_name_prefix_chars_editor, |me, _, event, ctx| { + me.handle_git_branch_edge_chars_editor_event(event, true, ctx); + }); + + let git_branch_name_suffix_chars_editor = ctx.add_typed_action_view(|ctx| { + EditorView::single_line(width_and_height_editor_options.clone(), ctx) + }); + git_branch_name_suffix_chars_editor.update(ctx, |editor, ctx| { + editor.set_buffer_text( + &format!( + "{}", + *TerminalSettings::as_ref(ctx).git_branch_name_suffix_chars + ), + ctx, + ); + }); + ctx.subscribe_to_view(&git_branch_name_suffix_chars_editor, |me, _, event, ctx| { + me.handle_git_branch_edge_chars_editor_event(event, false, ctx); + }); + let mouse_scroll_input_editor = ctx.add_typed_action_view(|ctx| { EditorView::single_line(width_and_height_editor_options.clone(), ctx) }); @@ -2753,6 +2879,11 @@ impl FeaturesPageView { graphics_backend_dropdown, new_tab_placement_dropdown, osc52_clipboard_access_dropdown, + git_branch_name_truncation_dropdown, + git_branch_name_prefix_chars_editor, + git_branch_name_suffix_chars_editor, + git_branch_prefix_chars_valid: true, + git_branch_suffix_chars_valid: true, default_session_mode_dropdown, tab_behavior: Default::default(), @@ -3054,6 +3185,8 @@ impl FeaturesPageView { terminal_widgets.push(Box::new(SmartSelectWidget::default())); terminal_widgets.push(Box::new(CopyOnSelectWidget::default())); terminal_widgets.push(Box::new(Osc52ClipboardAccessWidget::default())); + terminal_widgets.push(Box::new(GitBranchNameTruncationWidget::default())); + terminal_widgets.push(Box::new(GitBranchNameMiddleCharsWidget::default())); terminal_widgets.push(Box::new(NewTabPlacementWidget::default())); let mut system_widgets: Vec>> = vec![]; @@ -3713,6 +3846,146 @@ impl FeaturesPageView { }); } + fn update_git_branch_name_truncation_dropdown( + dropdown: ViewHandle>, + ctx: &mut ViewContext, + ) { + dropdown.update(ctx, |dropdown, ctx| { + let values = vec![ + GitBranchNameTruncation::OmitStart, + GitBranchNameTruncation::OmitEnd, + GitBranchNameTruncation::OmitMiddle, + ]; + let current_value = *TerminalSettings::as_ref(ctx).git_branch_name_truncation; + let selected_index = values + .iter() + .position(|val| *val == current_value) + .unwrap_or(0); + + dropdown.set_items( + values + .into_iter() + .map(|val| { + DropdownItem::new( + val.as_dropdown_label(), + FeaturesPageAction::SetGitBranchNameTruncation(val), + ) + }) + .collect(), + ctx, + ); + dropdown.set_selected_by_index(selected_index, ctx); + }); + } + + fn sync_git_branch_edge_chars_editor( + &self, + editor: ViewHandle, + saved: usize, + ctx: &mut ViewContext, + ) { + let saved = saved.to_string(); + editor.update(ctx, |editor, ctx| { + if editor.buffer_text(ctx) != saved { + editor.set_buffer_text(&saved, ctx); + } + }); + } + + fn handle_git_branch_edge_chars_editor_event( + &mut self, + event: &EditorEvent, + prefix: bool, + ctx: &mut ViewContext, + ) { + match event { + EditorEvent::Edited(_) => { + let editor = if prefix { + self.git_branch_name_prefix_chars_editor.clone() + } else { + self.git_branch_name_suffix_chars_editor.clone() + }; + let valid = editor + .as_ref(ctx) + .buffer_text(ctx) + .parse::() + .is_ok_and(|input| input <= GIT_BRANCH_NAME_EDGE_CHARS_LIMIT); + if prefix { + self.git_branch_prefix_chars_valid = valid; + } else { + self.git_branch_suffix_chars_valid = valid; + } + ctx.notify(); + } + EditorEvent::Enter | EditorEvent::Blurred => { + self.save_git_branch_edge_chars(prefix, ctx); + } + EditorEvent::Escape => ctx.emit(FeaturesSettingsPageEvent::FocusModal), + _ => {} + } + } + + fn save_git_branch_edge_chars(&mut self, prefix: bool, ctx: &mut ViewContext) { + let editor = if prefix { + self.git_branch_name_prefix_chars_editor.clone() + } else { + self.git_branch_name_suffix_chars_editor.clone() + }; + let buffer_text = editor.as_ref(ctx).buffer_text(ctx); + if let Ok(input) = buffer_text.parse::() { + let clamped = input.min(GIT_BRANCH_NAME_EDGE_CHARS_LIMIT); + if clamped != input { + editor.update(ctx, |editor, ctx| { + editor.set_buffer_text(&format!("{clamped}"), ctx); + }); + } + if prefix { + self.git_branch_prefix_chars_valid = true; + } else { + self.git_branch_suffix_chars_valid = true; + } + let current = if prefix { + *TerminalSettings::as_ref(ctx).git_branch_name_prefix_chars + } else { + *TerminalSettings::as_ref(ctx).git_branch_name_suffix_chars + }; + if current == clamped { + ctx.notify(); + return; + } + TerminalSettings::handle(ctx).update(ctx, |settings, ctx| { + if prefix { + report_if_error!( + settings + .git_branch_name_prefix_chars + .set_value(clamped, ctx) + ); + } else { + report_if_error!( + settings + .git_branch_name_suffix_chars + .set_value(clamped, ctx) + ); + } + }); + } else { + let saved = if prefix { + *TerminalSettings::as_ref(ctx).git_branch_name_prefix_chars + } else { + *TerminalSettings::as_ref(ctx).git_branch_name_suffix_chars + }; + editor.update(ctx, |editor, ctx| { + editor.set_buffer_text(&format!("{saved}"), ctx); + }); + if prefix { + self.git_branch_prefix_chars_valid = true; + } else { + self.git_branch_suffix_chars_valid = true; + } + ctx.notify(); + } + } + fn update_default_session_mode_dropdown( dropdown: ViewHandle>, ctx: &mut ViewContext, @@ -7407,6 +7680,151 @@ impl SettingsWidget for Osc52ClipboardAccessWidget { } } +#[derive(Default)] +struct GitBranchNameTruncationWidget {} + +impl SettingsWidget for GitBranchNameTruncationWidget { + type View = FeaturesPageView; + + fn search_terms(&self) -> &str { + "git branch name truncation prompt chip ticket prefix omit start end middle ellipsis" + } + + fn render( + &self, + view: &Self::View, + appearance: &Appearance, + app: &AppContext, + ) -> Box { + render_dropdown_item( + appearance, + "Long git branch names", + Some( + "Choose which part of a long git branch name the prompt replaces with an ellipsis.", + ), + None, + LocalOnlyIconState::for_setting( + GitBranchNameTruncationSetting::storage_key(), + GitBranchNameTruncationSetting::sync_to_cloud(), + &mut view + .button_mouse_states + .local_only_icon_tooltip_states + .borrow_mut(), + app, + ), + None, + &view.git_branch_name_truncation_dropdown, + ) + } +} + +#[derive(Default)] +struct GitBranchNameMiddleCharsWidget {} + +impl GitBranchNameMiddleCharsWidget { + fn edge_input( + label: &'static str, + editor: ViewHandle, + valid: bool, + appearance: &Appearance, + ) -> Box { + let theme = appearance.theme(); + let border_color: Option = if valid { + None + } else { + Some(themes::theme::Fill::error().into()) + }; + Flex::row() + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_child( + Text::new_inline( + label, + appearance.ui_font_family(), + appearance.ui_font_size() - 2., + ) + .with_color(theme.active_ui_text_color().into()) + .finish(), + ) + .with_child( + Container::new( + appearance + .ui_builder() + .text_input(editor) + .with_style(UiComponentStyles { + width: Some(GIT_BRANCH_EDGE_CHARS_INPUT_WIDTH), + padding: Some(Coords { + top: 4., + bottom: 4., + left: 6., + right: 6., + }), + background: Some(theme.surface_2().into()), + border_color, + ..Default::default() + }) + .build() + .finish(), + ) + .with_margin_left(6.) + .with_margin_right(12.) + .finish(), + ) + .finish() + } +} + +impl SettingsWidget for GitBranchNameMiddleCharsWidget { + type View = FeaturesPageView; + + fn search_terms(&self) -> &str { + "git branch name middle ellipsis characters prefix suffix start end prompt chip" + } + + fn render( + &self, + view: &Self::View, + appearance: &Appearance, + app: &AppContext, + ) -> Box { + let inputs = Flex::row() + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_child(Self::edge_input( + "Start", + view.git_branch_name_prefix_chars_editor.clone(), + view.git_branch_prefix_chars_valid, + appearance, + )) + .with_child(Self::edge_input( + "End", + view.git_branch_name_suffix_chars_editor.clone(), + view.git_branch_suffix_chars_valid, + appearance, + )) + .finish(); + + render_body_item::( + "Characters kept at each end".into(), + None, + LocalOnlyIconState::for_setting( + GitBranchNamePrefixChars::storage_key(), + GitBranchNamePrefixChars::sync_to_cloud(), + &mut view + .button_mouse_states + .local_only_icon_tooltip_states + .borrow_mut(), + app, + ), + ToggleState::Enabled, + appearance, + inputs, + Some( + "Used only when Long git branch names is set to Omit the middle. The prompt keeps this many characters from the start and from the end, with an ellipsis between them." + .to_string(), + ), + ) + } +} + #[derive(Default)] struct NewTabPlacementWidget {} diff --git a/app/src/settings_view/mod.rs b/app/src/settings_view/mod.rs index 18632a97c66..d749a8b18b7 100644 --- a/app/src/settings_view/mod.rs +++ b/app/src/settings_view/mod.rs @@ -570,6 +570,7 @@ pub mod flags { pub const SYNC_ALL_TABS_FLAG: &str = "Sync_All_Tabs_Enabled"; pub const SYNC_ALL_PANES_IN_CURRENT_TAB: &str = "Sync_All_Panes_In_Current_Tab"; pub const USE_AUDIBLE_BELL_CONTEXT_FLAG: &str = "Use_Audible_Terminal_Bell"; + pub const GIT_BRANCH_NAME_SHOW_START: &str = "Git_Branch_Name_Show_Start"; pub const SHOW_INPUT_HINT_TEXT_CONTEXT_FLAG: &str = "Show_Input_Hint_text"; pub const SHOW_AGENT_TIPS_FLAG: &str = "Show_Agent_Tips"; pub const SHOW_OZ_UPDATES_IN_ZERO_STATE_FLAG: &str = "Show_Oz_Updates_In_Zero_State"; diff --git a/app/src/terminal/settings.rs b/app/src/terminal/settings.rs index 81c398e2802..b1cac34de17 100644 --- a/app/src/terminal/settings.rs +++ b/app/src/terminal/settings.rs @@ -6,6 +6,10 @@ use warpui::units::Pixels; use warpui::{AppContext, SingletonEntity}; use crate::settings::{AISettings, InputSettings, TerminalSpacing}; +pub use crate::util::git_branch_display::{ + DEFAULT_GIT_BRANCH_NAME_PREFIX_CHARS, DEFAULT_GIT_BRANCH_NAME_SUFFIX_CHARS, + GitBranchNameTruncation, +}; #[derive( Clone, @@ -206,6 +210,36 @@ define_settings_group!(TerminalSettings, settings: [ toml_path: "experimental.async_find_enabled", description: "Use an improved implementation of find to keep the UI responsive while searching for matches on large outputs.", }, + git_branch_name_truncation: GitBranchNameTruncationSetting { + type: GitBranchNameTruncation, + default: GitBranchNameTruncation::default(), + supported_platforms: SupportedPlatforms::ALL, + sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes), + surface: settings::SettingSurfaces::GUI, + private: false, + toml_path: "terminal.prompt.git_branch_name_truncation", + description: "Which part of a long git branch name the prompt chip replaces with an ellipsis. `omit_start` hides the beginning and is the original behavior. `omit_end` hides the end so a ticket prefix stays visible. `omit_middle` hides the middle and keeps the configured number of characters from each end.", + }, + git_branch_name_prefix_chars: GitBranchNamePrefixChars { + type: usize, + default: DEFAULT_GIT_BRANCH_NAME_PREFIX_CHARS, + supported_platforms: SupportedPlatforms::ALL, + sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes), + surface: settings::SettingSurfaces::GUI, + private: false, + toml_path: "terminal.prompt.git_branch_name_prefix_chars", + description: "When long git branch names omit the middle, how many characters to keep from the start. Ignored for the other truncation modes.", + }, + git_branch_name_suffix_chars: GitBranchNameSuffixChars { + type: usize, + default: DEFAULT_GIT_BRANCH_NAME_SUFFIX_CHARS, + supported_platforms: SupportedPlatforms::ALL, + sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes), + surface: settings::SettingSurfaces::GUI, + private: false, + toml_path: "terminal.prompt.git_branch_name_suffix_chars", + description: "When long git branch names omit the middle, how many characters to keep from the end. Ignored for the other truncation modes.", + }, ]); impl TerminalSettings { diff --git a/app/src/util/git_branch_display.rs b/app/src/util/git_branch_display.rs new file mode 100644 index 00000000000..9a88c7bfb92 --- /dev/null +++ b/app/src/util/git_branch_display.rs @@ -0,0 +1,129 @@ +use serde::{Deserialize, Serialize}; + +use super::truncation::{truncate_from_beginning, truncate_from_end}; + +/// Largest leading or trailing character count a user can save. +/// +/// The prompt chip itself is capped separately. Counts above that cap are reduced while formatting +/// so the ellipsis still fits. +pub const GIT_BRANCH_NAME_EDGE_CHARS_LIMIT: usize = 40; + +/// Characters kept from the start when the middle of a branch name is hidden. +pub const DEFAULT_GIT_BRANCH_NAME_PREFIX_CHARS: usize = 12; + +/// Characters kept from the end when the middle of a branch name is hidden. +pub const DEFAULT_GIT_BRANCH_NAME_SUFFIX_CHARS: usize = 12; + +/// Which part of a long git branch name the prompt chip replaces with an ellipsis. +#[derive( + Clone, + Copy, + Debug, + Default, + PartialEq, + Eq, + Serialize, + Deserialize, + schemars::JsonSchema, + settings_value::SettingsValue, +)] +#[schemars( + description = "Which part of a long git branch name the prompt chip replaces with an ellipsis.", + rename_all = "snake_case" +)] +pub enum GitBranchNameTruncation { + /// Hide the start of the name. This is the original prompt behavior. + #[default] + #[schemars(description = "Hide the start of a long branch name.")] + OmitStart, + /// Hide the end of the name so a prefix, such as a ticket id, stays visible. + #[schemars(description = "Hide the end of a long branch name.")] + OmitEnd, + /// Hide the middle and keep a fixed number of characters from each end. + #[schemars(description = "Hide the middle of a long branch name.")] + OmitMiddle, +} + +impl GitBranchNameTruncation { + pub fn as_dropdown_label(self) -> &'static str { + match self { + Self::OmitStart => "Omit the start", + Self::OmitEnd => "Omit the end", + Self::OmitMiddle => "Omit the middle", + } + } + + /// Switches between hiding the start and hiding the end. + /// + /// Middle omission is not part of that pair. Toggling it shows the start of the name instead. + pub fn toggled(self) -> Self { + match self { + Self::OmitStart | Self::OmitMiddle => Self::OmitEnd, + Self::OmitEnd => Self::OmitStart, + } + } +} + +/// Formats a git branch name for the prompt chip. +/// +/// `prefix_chars` and `suffix_chars` apply only when `truncation` hides the middle. A shortened +/// name always keeps an ellipsis where text was removed. +pub fn format_git_branch_name( + branch: &str, + truncation: GitBranchNameTruncation, + prefix_chars: usize, + suffix_chars: usize, + max_chars: usize, +) -> String { + match truncation { + GitBranchNameTruncation::OmitStart => truncate_from_beginning(branch, max_chars), + GitBranchNameTruncation::OmitEnd => truncate_from_end(branch, max_chars), + GitBranchNameTruncation::OmitMiddle => { + truncate_middle(branch, prefix_chars, suffix_chars, max_chars) + } + } +} + +/// Hides the middle of `text`. +/// +/// Keeps `prefix_chars` from the start and `suffix_chars` from the end. A name no longer than those +/// two edges is shown in full. When the edges plus the ellipsis would pass `max_chars`, the suffix +/// shrinks before the prefix so a ticket id at the start survives. +fn truncate_middle( + text: &str, + prefix_chars: usize, + suffix_chars: usize, + max_chars: usize, +) -> String { + if max_chars == 0 { + return String::new(); + } + let chars: Vec = text.chars().collect(); + let len = chars.len(); + if len <= max_chars && len <= prefix_chars.saturating_add(suffix_chars) { + return text.to_string(); + } + + let mut prefix = prefix_chars.min(len); + let mut suffix = suffix_chars.min(len); + let edge_budget = max_chars.saturating_sub(1); + if prefix + suffix > edge_budget { + let overflow = prefix + suffix - edge_budget; + let suffix_reduction = overflow.min(suffix); + suffix -= suffix_reduction; + prefix = prefix.saturating_sub(overflow - suffix_reduction); + } + if prefix + suffix >= len { + return text.to_string(); + } + if prefix == 0 && suffix == 0 { + return "…".to_string(); + } + let start: String = chars[..prefix].iter().collect(); + let end: String = chars[len - suffix..].iter().collect(); + format!("{start}…{end}") +} + +#[cfg(test)] +#[path = "git_branch_display_tests.rs"] +mod tests; diff --git a/app/src/util/git_branch_display_tests.rs b/app/src/util/git_branch_display_tests.rs new file mode 100644 index 00000000000..dc67478f022 --- /dev/null +++ b/app/src/util/git_branch_display_tests.rs @@ -0,0 +1,155 @@ +use settings_value::SettingsValue; + +use super::{ + DEFAULT_GIT_BRANCH_NAME_PREFIX_CHARS, DEFAULT_GIT_BRANCH_NAME_SUFFIX_CHARS, + GitBranchNameTruncation, format_git_branch_name, +}; +use crate::util::truncation::{truncate_from_beginning, truncate_from_end}; + +const LONG_BRANCH: &str = "ABC-123-some-long-feature-that-needs-to-be-implemented"; + +fn format( + branch: &str, + truncation: GitBranchNameTruncation, + prefix_chars: usize, + suffix_chars: usize, + max_chars: usize, +) -> String { + format_git_branch_name(branch, truncation, prefix_chars, suffix_chars, max_chars) +} + +#[test] +fn omit_start_matches_existing_beginning_truncation() { + let displayed = format(LONG_BRANCH, GitBranchNameTruncation::OmitStart, 12, 12, 40); + assert_eq!(displayed, truncate_from_beginning(LONG_BRANCH, 40)); + assert!(displayed.starts_with('…')); + assert!(!displayed.contains("ABC-123")); +} + +#[test] +fn omit_end_keeps_the_ticket_prefix_and_the_ellipsis() { + let displayed = format(LONG_BRANCH, GitBranchNameTruncation::OmitEnd, 3, 3, 40); + assert_eq!(displayed, truncate_from_end(LONG_BRANCH, 40)); + assert!(displayed.starts_with("ABC-123")); + assert!(displayed.ends_with('…')); + assert_eq!(displayed.chars().count(), 40); +} + +#[test] +fn edge_counts_do_not_change_start_or_end_omission() { + let with_defaults = format(LONG_BRANCH, GitBranchNameTruncation::OmitStart, 12, 12, 40); + let with_other_counts = format(LONG_BRANCH, GitBranchNameTruncation::OmitStart, 1, 30, 40); + assert_eq!(with_defaults, with_other_counts); +} + +#[test] +fn short_names_keep_every_character() { + let branch = "main"; + for truncation in [ + GitBranchNameTruncation::OmitStart, + GitBranchNameTruncation::OmitEnd, + GitBranchNameTruncation::OmitMiddle, + ] { + assert_eq!(format(branch, truncation, 12, 12, 40), branch); + } +} + +#[test] +fn omit_middle_keeps_both_edges_and_the_ellipsis() { + let displayed = format( + LONG_BRANCH, + GitBranchNameTruncation::OmitMiddle, + DEFAULT_GIT_BRANCH_NAME_PREFIX_CHARS, + DEFAULT_GIT_BRANCH_NAME_SUFFIX_CHARS, + 40, + ); + assert_eq!(displayed, "ABC-123-some…-implemented"); + assert!(displayed.contains('…')); + assert_ne!(displayed, LONG_BRANCH); +} + +#[test] +fn omit_middle_shows_the_name_when_the_edges_cover_it() { + let branch = "ABC-123-short"; + assert_eq!( + format(branch, GitBranchNameTruncation::OmitMiddle, 8, 8, 40), + branch + ); +} + +#[test] +fn omit_middle_shrinks_the_suffix_before_the_prefix() { + let displayed = format(LONG_BRANCH, GitBranchNameTruncation::OmitMiddle, 30, 30, 40); + assert_eq!(displayed.chars().count(), 40); + let without_ellipsis: String = displayed.chars().filter(|c| *c != '…').collect(); + assert_eq!(without_ellipsis.chars().count(), 39); + assert!(displayed.starts_with(&LONG_BRANCH.chars().take(30).collect::())); + assert!(LONG_BRANCH.ends_with(&displayed.chars().skip(31).collect::())); + assert_eq!(displayed.chars().nth(30), Some('…')); +} + +#[test] +fn omit_middle_with_no_edge_characters_is_an_ellipsis() { + assert_eq!( + format(LONG_BRANCH, GitBranchNameTruncation::OmitMiddle, 0, 0, 40), + "…" + ); +} + +#[test] +fn unicode_omit_end_keeps_char_boundaries() { + let branch = "票-1-很长的分支名字需要被截断才能放进提示"; + let displayed = format(branch, GitBranchNameTruncation::OmitEnd, 0, 0, 8); + assert!(displayed.starts_with("票-1")); + assert!(displayed.ends_with('…')); + assert_eq!(displayed.chars().count(), 8); + assert!(branch.starts_with(displayed.trim_end_matches('…'))); +} + +#[test] +fn unicode_omit_middle_keeps_char_boundaries() { + let branch = "票-1-很长的分支名字需要被截断才能放进提示"; + let displayed = format(branch, GitBranchNameTruncation::OmitMiddle, 3, 2, 40); + assert_eq!(displayed, "票-1…提示"); +} + +#[test] +fn truncation_serializes_as_snake_case() { + assert_eq!( + GitBranchNameTruncation::default(), + GitBranchNameTruncation::OmitStart + ); + assert_eq!( + GitBranchNameTruncation::OmitStart.to_file_value(), + serde_json::json!("omit_start") + ); + assert_eq!( + GitBranchNameTruncation::OmitEnd.to_file_value(), + serde_json::json!("omit_end") + ); + assert_eq!( + GitBranchNameTruncation::OmitMiddle.to_file_value(), + serde_json::json!("omit_middle") + ); + assert_eq!( + GitBranchNameTruncation::from_file_value(&serde_json::json!("omit_middle")).unwrap(), + GitBranchNameTruncation::OmitMiddle + ); + assert!(GitBranchNameTruncation::from_file_value(&serde_json::json!("end")).is_none()); +} + +#[test] +fn toggled_switches_between_hiding_the_start_and_the_end() { + assert_eq!( + GitBranchNameTruncation::OmitStart.toggled(), + GitBranchNameTruncation::OmitEnd + ); + assert_eq!( + GitBranchNameTruncation::OmitEnd.toggled(), + GitBranchNameTruncation::OmitStart + ); + assert_eq!( + GitBranchNameTruncation::OmitMiddle.toggled(), + GitBranchNameTruncation::OmitEnd + ); +} diff --git a/app/src/util/mod.rs b/app/src/util/mod.rs index 2ed14db4899..fa039bf7162 100644 --- a/app/src/util/mod.rs +++ b/app/src/util/mod.rs @@ -5,6 +5,7 @@ pub mod extensions; #[cfg(feature = "local_fs")] pub mod file; pub mod git; +pub(crate) mod git_branch_display; pub mod image; pub(crate) mod link_detection; pub mod links; diff --git a/app/src/workspace/view.rs b/app/src/workspace/view.rs index c25484fab76..ec63cc9e858 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -418,7 +418,7 @@ use crate::terminal::session_settings::{ NewSessionSource, NotificationsMode, NotificationsSettings, SessionSettings, SessionSettingsChangedEvent, WorkingDirectoryMode, }; -use crate::terminal::settings::{SpacingMode, TerminalSettings}; +use crate::terminal::settings::{GitBranchNameTruncation, SpacingMode, TerminalSettings}; #[cfg(feature = "integration_tests")] use crate::terminal::shared_session::SharedSessionSource; use crate::terminal::shared_session::{IsSharedSessionCreator, SharedSessionActionSource}; @@ -23601,6 +23601,12 @@ impl Workspace { if *terminal_settings.use_audible_bell { context.set.insert(flags::USE_AUDIBLE_BELL_CONTEXT_FLAG); } + if matches!( + *terminal_settings.git_branch_name_truncation, + GitBranchNameTruncation::OmitEnd + ) { + context.set.insert(flags::GIT_BRANCH_NAME_SHOW_START); + } if *terminal_settings.show_terminal_zero_state_block.value() { context .set