use std::path::PathBuf; use galaxyui::{ elements::ChildView, ui_components::components::UiComponentStyles, AppContext, Element, Entity, TypedActionView, View, ViewContext, ViewHandle, }; use crate::{ code_review::diff_state::DiffStateModel, tab_configs::PickerStyle, util::git::detect_current_branch, view_components::{DropdownItem, FilterableDropdown}, }; const DEFAULT_DROPDOWN_WIDTH: f32 = 380.; /// Placeholder text shown in the dropdown top bar while branches are loading. const LOADING_PLACEHOLDER: &str = "Fetching branches\u{2026}"; /// A filterable dropdown that lists local git branches for the given repo path. /// /// Created with an optional `cwd` — if `None`, the picker starts with the /// default value pre-populated while the async fetch runs. /// When branches are available, main branches are sorted to the top. /// /// Emits the selected branch name (a `String`) as its event. pub struct BranchPicker { dropdown: ViewHandle>, /// Pre-selected default value from the TOML `default =` field, used to /// restore a selection after the async branch list arrives. default_value: Option, /// Monotonically increasing counter incremented on every `fetch_branches` call. /// The async callback compares against the epoch captured at spawn time and /// discards stale results, preventing a slow earlier fetch from overwriting a /// faster later one when the repo changes mid-flight. fetch_epoch: usize, /// Main branch name cached after the first successful fetch for this repo. /// Passed to `get_all_branches_with_known_main` on subsequent fetches to skip /// the `detect_main_branch` step (which can make up to 6 sequential subprocess /// calls). Cleared in `refetch_branches` because a different repo may have a /// different main branch. cached_main_branch: Option, /// True while an async branch fetch is in-flight. While loading, the /// dropdown is disabled so the user cannot interact with an empty list. is_loading: bool, } impl BranchPicker { /// Creates a new picker and immediately spawns an async fetch for the /// branches of the repo at `cwd`. `default_value` is pre-selected once /// the list arrives (if it appears in the list). pub fn new( cwd: Option, default_value: Option, ctx: &mut ViewContext, ) -> Self { Self::new_with_style(cwd, default_value, None, ctx) } pub fn new_with_style( cwd: Option, default_value: Option, style: Option, ctx: &mut ViewContext, ) -> Self { let width = style.as_ref().map_or(DEFAULT_DROPDOWN_WIDTH, |s| s.width); let bg = style.and_then(|s| s.background); let dropdown = ctx.add_typed_action_view(|ctx| { let mut dropdown = FilterableDropdown::new(ctx); dropdown.set_top_bar_max_width(width); dropdown.set_menu_width(width, ctx); if let Some(bg) = bg { dropdown.set_style(UiComponentStyles { background: Some(bg.into()), ..Default::default() }); } dropdown }); let mut picker = Self { dropdown, default_value: default_value.clone(), fetch_epoch: 0, cached_main_branch: None, is_loading: false, }; // Synchronously show the default value immediately so the dropdown is // never empty while the async branch fetch is in flight (or if the // repo has no commits / is not a git repo). if let Some(ref default) = default_value { let default = default.clone(); picker.dropdown.update(ctx, |dropdown, ctx| { dropdown.set_items( vec![DropdownItem::new(default.clone(), default.clone())], ctx, ); dropdown.set_selected_by_name(default.as_str(), ctx); }); } // Kick off the async branch fetch to replace the placeholder with // the full list of branches from the repo. if let Some(cwd) = cwd { picker.fetch_branches(cwd, ctx); } picker } /// Fetches branches for `cwd` asynchronously and populates the dropdown. /// /// On the first call for a given repo this runs `get_all_branches`, which /// internally calls `detect_main_branch` (up to 6 sequential subprocess calls). /// The detected main branch is cached and reused on all subsequent calls via /// `get_all_branches_with_known_main`, reducing each refetch to a single /// `git for-each-ref` invocation. /// /// A `fetch_epoch` counter guards against stale results: if a second fetch /// completes before a slower first one, the first result is silently discarded. fn fetch_branches(&mut self, cwd: PathBuf, ctx: &mut ViewContext) { self.is_loading = true; self.dropdown.update(ctx, |dropdown, ctx| { dropdown.set_disabled(ctx); // Show loading text in the dropdown top bar so the modal // doesn't shift layout while the fetch is in-flight. let placeholder = DropdownItem::new(LOADING_PLACEHOLDER.to_string(), String::new()); dropdown.set_items(vec![placeholder], ctx); dropdown.set_selected_by_name(LOADING_PLACEHOLDER, ctx); }); self.fetch_epoch += 1; let epoch = self.fetch_epoch; let known_main = self.cached_main_branch.clone(); ctx.spawn( async move { let branches = match known_main { Some(ref main) => { DiffStateModel::get_all_branches_with_known_main(&cwd, main, None, false) .await } None => DiffStateModel::get_all_branches(&cwd, None, false).await, }; // git for-each-ref only lists refs backed by actual commits, // so a freshly initialised repo (`git init`, no commits yet) // returns an empty list even though HEAD points to a valid // branch name (e.g. "main"). Fall back to the current branch // so the picker still has a usable entry. match branches { Ok(ref list) if list.is_empty() => { if let Ok(current) = detect_current_branch(&cwd).await { let trimmed = current.trim().to_string(); if !trimmed.is_empty() { return Ok(vec![(trimmed, true)]); } } branches } _ => branches, } }, move |me, result, ctx| { // Discard results from a superseded fetch. if me.fetch_epoch != epoch { return; } me.is_loading = false; me.dropdown.update(ctx, |dropdown, ctx| { dropdown.set_enabled(ctx); }); let branches = match result { Ok(branches) => branches, Err(err) => { log::warn!("BranchPicker: failed to fetch branches: {err}"); Vec::new() } }; // Cache the detected main branch for subsequent refetches so // detect_main_branch is only run once per repo. if me.cached_main_branch.is_none() { me.cached_main_branch = branches .iter() .find(|(_, is_main)| *is_main) .map(|(name, _)| name.clone()); } // Main branches first, then the rest in recency order. let mut items: Vec> = DiffStateModel::sort_branches_main_first(&branches) .map(|(name, _)| DropdownItem::new(name.clone(), name.clone())) .collect(); // Add the default as the first item if it isn't already in the list // (e.g. the user typed a branch name that doesn't exist locally yet). if let Some(ref default) = me.default_value { if !branches.iter().any(|(name, _)| name == default) { items.insert(0, DropdownItem::new(default.clone(), default.clone())); } } // Determine which branch to auto-select: explicit default // first, then the detected main branch — but only if the // branch actually exists in the fetched list. let auto_select = me .default_value .clone() .or_else(|| me.cached_main_branch.clone()) .filter(|name| items.iter().any(|item| item.display_text == *name)); me.dropdown.update(ctx, |dropdown, ctx| { dropdown.set_items(items, ctx); if let Some(ref name) = auto_select { dropdown.set_selected_by_name(name.as_str(), ctx); } }); // Emit the auto-selected value so parent views (e.g. the // worktree modal) update their state without requiring // an explicit user click. if let Some(ref name) = auto_select { ctx.emit(name.clone()); } ctx.notify(); }, ); } /// Clears the current branch list and re-fetches for a new repo path. /// /// Called when the user changes the repo selection in the params modal. /// Clears stale items immediately so the dropdown shows empty while loading. /// Also clears `cached_main_branch` because the new repo may have a different /// main branch name. pub fn refetch_branches(&mut self, new_cwd: PathBuf, ctx: &mut ViewContext) { self.default_value = None; self.cached_main_branch = None; self.dropdown.update(ctx, |dropdown, ctx| { dropdown.set_items(vec![], ctx); // Clear the cached selected_item so the closed dropdown no longer // shows a stale branch name from the previous repo. dropdown.set_selected_by_name("", ctx); }); self.fetch_branches(new_cwd, ctx); } pub fn toggle_dropdown(&mut self, ctx: &mut ViewContext) -> bool { self.dropdown.update(ctx, |dropdown, ctx| { dropdown.toggle_expanded(ctx); }); self.dropdown.as_ref(ctx).is_expanded() } pub fn selected_value(&self, app: &AppContext) -> Option { // While loading, the dropdown shows a placeholder label // ("Fetching branches…") that must not be treated as a real // branch selection. if self.is_loading { return None; } self.dropdown.as_ref(app).selected_item_label() } /// Returns `true` while an async branch fetch is in-flight. pub fn is_loading(&self) -> bool { self.is_loading } } impl Entity for BranchPicker { type Event = String; } impl View for BranchPicker { fn ui_name() -> &'static str { "BranchPicker" } fn render(&self, _app: &AppContext) -> Box { ChildView::new(&self.dropdown).finish() } } impl TypedActionView for BranchPicker { type Action = String; fn handle_action(&mut self, action: &String, ctx: &mut ViewContext) { ctx.emit(action.clone()); } }