use super::*;
use gpui::{AnyElement, Div, Stateful};
use rustc_hash::FxHashSet;

const STATUS_SECTION_MIN_HEIGHT_PX: f32 = 80.0;

use crate::view::commit_message_text::{
    TextHighlights, commit_link_style, commit_message_summary_highlights,
};
type MessageLinks = Arc<[components::MessageLink]>;
type CommitMessageLinkHighlights = (TextHighlights, MessageLinks);

fn merge_active(repo: Option<&RepoState>) -> bool {
    repo.is_some_and(|r| matches!(&r.merge_commit_message, Loadable::Ready(Some(_))))
}

fn commit_allowed(is_merge_active: bool, staged_count: usize) -> bool {
    staged_count > 0 || is_merge_active
}

/// Author identity block: avatar + name + muted email, with the authored date
/// as a relative label (absolute date lives in the "Commit date" row below).
fn commit_details_author_row(
    theme: AppTheme,
    ui_scale: crate::ui_scale::UiScale,
    details: &gitcomet_core::domain::CommitDetails,
) -> Option<Div> {
    if details.author_name.is_empty() && details.author_email.is_empty() {
        return None;
    }
    let display_name = if details.author_name.is_empty() {
        details.author_email.clone()
    } else {
        details.author_name.clone()
    };
    let authored_relative = (details.authored_at_unix != 0).then(|| {
        crate::view::date_time::format_relative_time(
            details.authored_at_unix,
            std::time::SystemTime::now(),
        )
    });

    Some(
        div()
            .flex()
            .items_center()
            .gap_2()
            .w_full()
            .min_w(px(0.0))
            .child(components::author_avatar(theme, ui_scale, &display_name))
            .child(
                div()
                    .flex_1()
                    .min_w(px(0.0))
                    .flex()
                    .flex_col()
                    .child(
                        div()
                            .text_sm()
                            .line_clamp(1)
                            .whitespace_nowrap()
                            .child(display_name),
                    )
                    .when(!details.author_email.is_empty(), |column| {
                        column.child(
                            div()
                                .text_xs()
                                .text_color(theme.colors.foreground.secondary)
                                .line_clamp(1)
                                .whitespace_nowrap()
                                .child(details.author_email.clone()),
                        )
                    }),
            )
            .when_some(authored_relative, |row, relative| {
                row.child(
                    div()
                        .flex_none()
                        .text_xs()
                        .text_color(theme.colors.foreground.secondary)
                        .child(relative),
                )
            }),
    )
}

const MULTI_COMMIT_ROW_HEIGHT_PX: f32 = 44.0;

/// How much of the comparison body the compared-commit cards may fill before
/// they start scrolling instead of growing. A range comparison has two
/// endpoints, but a multi-selection comparison has one card per selected
/// commit, and an unbounded column of those would push the changed-file list —
/// the part the user actually came for — off the bottom of the pane. At half,
/// the two lists split the body evenly once the selection is large enough to
/// need it, whatever height the pane happens to have.
const COMPARISON_CARDS_MAX_BODY_FRACTION: f32 = 0.5;

/// Floor for the comparison's changed-file section — a label plus a row or two
/// of list. Keeps the capped card block above it from claiming the whole pane
/// when the pane is shorter than the card cap allows for.
const RANGE_FILES_SECTION_MIN_HEIGHT_PX: f32 = 44.0;

fn commit_details_selectable_row(theme: AppTheme, key: &'static str, value: AnyElement) -> Div {
    div()
        .flex()
        .flex_col()
        .gap_1()
        .child(
            div()
                .text_sm()
                .text_color(theme.colors.foreground.secondary)
                .child(key),
        )
        .child(div().w_full().min_w(px(0.0)).text_sm().child(value))
}

fn commit_details_monospace_value(input: Entity<components::TextInput>) -> AnyElement {
    commit_details_monospace_element(input.into_any_element())
}

fn commit_details_monospace_element(value: AnyElement) -> AnyElement {
    div()
        .font_family(crate::view::UI_MONOSPACE_FONT_FAMILY)
        .child(value)
        .into_any_element()
}

fn commit_message_link_highlights(message: &str, theme: AppTheme) -> CommitMessageLinkHighlights {
    use crate::text_selection::MessageLinkKind;

    let style = commit_link_style(theme);
    let found = crate::text_selection::commit_message_link_ranges(message);
    let highlights = found
        .iter()
        .map(|link| (link.range.clone(), style))
        .collect::<Vec<_>>();
    let links = found
        .into_iter()
        .map(|link| {
            let text = &message[link.range.clone()];
            let target = match link.kind {
                MessageLinkKind::CommitSha => components::LinkTarget::Commit {
                    commit_id: CommitId(text.to_ascii_lowercase().into()),
                    allow_navigate: true,
                },
                MessageLinkKind::Url => components::LinkTarget::Url(text.to_owned().into()),
            };
            components::MessageLink {
                range: link.range,
                target,
            }
        })
        .collect::<Vec<_>>();

    (highlights, Arc::from(links))
}

/// The whole of a SHA field is one link, without scanning: the field holds
/// nothing but the id.
fn commit_sha_field_links(sha: &str, interactive: bool, allow_navigate: bool) -> MessageLinks {
    if interactive {
        Arc::from([components::MessageLink {
            range: 0..sha.len(),
            target: components::LinkTarget::Commit {
                commit_id: CommitId(sha.to_string().into()),
                allow_navigate,
            },
        }])
    } else {
        Arc::<[components::MessageLink]>::from([])
    }
}

fn commit_sha_field_highlights(value: &str, theme: AppTheme) -> TextHighlights {
    if value.is_empty() || value == "—" {
        Vec::new()
    } else {
        vec![(0..value.len(), commit_link_style(theme))]
    }
}

fn min_change_tracking_stack_height(split_change_tracking: bool, handle_h: Pixels) -> Pixels {
    let section_min_h = px(STATUS_SECTION_MIN_HEIGHT_PX);
    if split_change_tracking {
        section_min_h * 2.0 + handle_h
    } else {
        section_min_h
    }
}

fn clamp_vertical_split_height(
    requested_top: Pixels,
    total_height: Pixels,
    min_top: Pixels,
    min_bottom: Pixels,
) -> Pixels {
    if total_height <= px(0.0) {
        return px(0.0);
    }

    let min_total = min_top + min_bottom;
    if total_height <= min_total {
        return (total_height - min_bottom).max(px(0.0));
    }

    requested_top.max(min_top).min(total_height - min_bottom)
}

fn resolved_vertical_split_height(
    requested_top: Option<Pixels>,
    total_height: Pixels,
    min_top: Pixels,
    min_bottom: Pixels,
) -> Pixels {
    if total_height <= px(0.0) {
        return px(0.0);
    }

    let default_top = (total_height * 0.5)
        .max(min_top)
        .min((total_height - min_bottom).max(px(0.0)));
    clamp_vertical_split_height(
        requested_top.unwrap_or(default_top),
        total_height,
        min_top,
        min_bottom,
    )
}

fn visible_bounds_probe() -> Div {
    // Use a fill probe to capture the clipped viewport bounds for a container.
    // Unioning child bounds can stay larger than the visible area after window resizes.
    div().absolute().top_0().left_0().size_full()
}

/// Which wording the changed-files section headers draw. The full labels stop
/// fitting once the details pane is dragged narrow, and an over-wide action
/// group shoves the section title out of the panel — so the labels collapse to
/// initials before that happens.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum StatusActionLabels {
    Full,
    Compact,
}

/// Average glyph advances at `text_sm` (14px at 100% zoom), regular and bold.
/// Budgeted rather than measured, the same way the history columns decide what
/// to drop (`view/panes/history.rs`).
///
/// Calibrated against the shipped UI font rather than guessed: at 100% zoom
/// `Stage all changes` renders 108px of ink over 17 characters (6.35/char) and
/// the bold `Unstaged` renders 59px over 8 (7.4/char). Guessed values that ran
/// a little high kept the header on the short labels while ~20px of room was
/// still going spare, so keep these honest — the header truncates its title
/// gracefully if a budget ever falls slightly short, but a budget that runs
/// long silently withholds the full wording.
const STATUS_ACTION_CHAR_WIDTH_PX: f32 = 6.4;
const STATUS_HEADER_TITLE_CHAR_WIDTH_PX: f32 = 7.4;
/// The header's own `px_2`, and the `gap_2` between the title and the action
/// group and between the buttons themselves.
const STATUS_HEADER_PAD_X_PX: f32 = 8.0;
const STATUS_HEADER_GAP_PX: f32 = 8.0;
/// A change-tracking dropdown title's `px_1` either side, its `gap_1`, and the
/// 12px chevron.
const STATUS_HEADER_DROPDOWN_EXTRA_PX: f32 = 24.0;
const STATUS_HEADER_SPINNER_PX: f32 = 14.0;

fn status_action_button_width_px(label_chars: usize) -> f32 {
    // `control_pad_x` each side, plus the 1px border every style reserves.
    2.0 * components::CONTROL_PAD_X_PX + 2.0 + label_chars as f32 * STATUS_ACTION_CHAR_WIDTH_PX
}

fn status_action_labels_for_width(
    available_width: Pixels,
    title_chars: usize,
    title_is_dropdown: bool,
    action_label_chars: &[usize],
    has_spinner: bool,
    ui_scale_percent: u32,
) -> StatusActionLabels {
    if available_width <= px(0.0) || action_label_chars.is_empty() {
        return StatusActionLabels::Full;
    }

    let mut needed = 2.0 * STATUS_HEADER_PAD_X_PX
        + title_chars as f32 * STATUS_HEADER_TITLE_CHAR_WIDTH_PX
        + if title_is_dropdown {
            STATUS_HEADER_DROPDOWN_EXTRA_PX
        } else {
            0.0
        }
        // Between the title and the action group.
        + STATUS_HEADER_GAP_PX;
    if has_spinner {
        needed += STATUS_HEADER_SPINNER_PX + STATUS_HEADER_GAP_PX;
    }
    for (ix, chars) in action_label_chars.iter().enumerate() {
        if ix > 0 {
            needed += STATUS_HEADER_GAP_PX;
        }
        needed += status_action_button_width_px(*chars);
    }

    if crate::ui_scale::design_px_from_percent(needed, ui_scale_percent) <= available_width {
        StatusActionLabels::Full
    } else {
        StatusActionLabels::Compact
    }
}

/// Clipped forms of the action verbs. A bare initial was ambiguous — `S` and
/// `U` read as the same family, and the two of them plus `D` gave no clue which
/// button did what. These stay pronounceable at a glance.
fn status_action_short_word(word: &'static str) -> &'static str {
    match word {
        "Stage" => "Stg",
        "Discard" => "Disc",
        "Unstage" => "Ustg",
        other => other,
    }
}

/// `Stage (3)` → `Stg (3)`, `Stage all changes` → `All`.
fn status_action_count_label(
    labels: StatusActionLabels,
    word: &'static str,
    count: usize,
) -> String {
    match labels {
        StatusActionLabels::Full => format!("{word} ({count})"),
        StatusActionLabels::Compact => {
            format!("{} ({count})", status_action_short_word(word))
        }
    }
}

fn status_action_all_label(labels: StatusActionLabels, full: &'static str) -> &'static str {
    match labels {
        StatusActionLabels::Full => full,
        StatusActionLabels::Compact => "All",
    }
}

fn status_action_file_count(count: usize) -> &'static str {
    if count == 1 { "file" } else { "files" }
}

#[derive(Clone, Debug, Default, Eq, PartialEq)]
struct StatusSectionActionSelection {
    paths: Vec<std::path::PathBuf>,
    from_explicit_selection: bool,
}

impl StatusSectionActionSelection {
    fn count(&self) -> usize {
        self.paths.len()
    }

    fn popover_path(&self) -> Option<std::path::PathBuf> {
        (!self.from_explicit_selection && self.paths.len() == 1).then(|| self.paths[0].clone())
    }
}

fn explicit_status_section_action_paths(
    selection: &StatusMultiSelection,
    section: StatusSection,
) -> Vec<std::path::PathBuf> {
    match section {
        StatusSection::CombinedUnstaged => selection
            .selected_paths_for_area(DiffArea::Unstaged)
            .to_vec(),
        StatusSection::Untracked => selection.untracked.clone(),
        StatusSection::Unstaged => selection.unstaged.clone(),
        StatusSection::Staged => selection.staged.clone(),
    }
}

fn active_status_section_action_path(
    repo: &RepoState,
    diff_target: Option<&DiffTarget>,
    section: StatusSection,
) -> Option<std::path::PathBuf> {
    let DiffTarget::WorkingTree { path, area } = diff_target? else {
        return None;
    };
    if *area != section.diff_area() {
        return None;
    }

    StatusSectionEntries::from_repo(repo, section)
        .is_some_and(|entries| entries.contains_path(path.as_path()))
        .then(|| path.clone())
}

fn status_section_action_selection(
    repo: &RepoState,
    diff_target: Option<&DiffTarget>,
    selection: Option<&StatusMultiSelection>,
    section: StatusSection,
) -> StatusSectionActionSelection {
    if let Some(selection) = selection {
        let paths = explicit_status_section_action_paths(selection, section);
        if !paths.is_empty() {
            return StatusSectionActionSelection {
                paths,
                from_explicit_selection: true,
            };
        }
    }

    active_status_section_action_path(repo, diff_target, section)
        .map(|path| StatusSectionActionSelection {
            paths: vec![path],
            from_explicit_selection: false,
        })
        .unwrap_or_default()
}

impl DetailsPaneView {
    fn status_section_action_selection(
        &self,
        repo_id: RepoId,
        section: StatusSection,
    ) -> StatusSectionActionSelection {
        let Some(repo) = self.active_repo().filter(|repo| repo.id == repo_id) else {
            return StatusSectionActionSelection::default();
        };

        status_section_action_selection(
            repo,
            repo.diff_state.diff_target.as_ref(),
            self.status_multi_selection.get(&repo_id),
            section,
        )
    }

    fn take_status_section_action_selection(
        &mut self,
        repo_id: RepoId,
        section: StatusSection,
    ) -> StatusSectionActionSelection {
        let selection = self.status_section_action_selection(repo_id, section);
        if selection.from_explicit_selection {
            self.status_multi_selection.remove(&repo_id);
        }
        selection
    }

    fn measured_status_sections_total_height(&self, resize_handle_h: Pixels) -> Option<Pixels> {
        self.current_status_sections_bounds()
            .map(|bounds| (bounds.size.height - resize_handle_h).max(px(0.0)))
    }

    fn resolved_measured_change_tracking_section_height(
        &self,
        resize_handle_h: Pixels,
    ) -> Option<Pixels> {
        let section_min_h = px(STATUS_SECTION_MIN_HEIGHT_PX);
        let min_height = min_change_tracking_stack_height(
            self.change_tracking_view == ChangeTrackingView::SplitUntracked,
            resize_handle_h,
        );

        self.measured_status_sections_total_height(resize_handle_h)
            .map(|total_height| {
                resolved_vertical_split_height(
                    self.change_tracking_height,
                    total_height,
                    min_height,
                    section_min_h,
                )
            })
    }

    fn resolved_measured_change_tracking_stack_total_height(
        &self,
        resize_handle_h: Pixels,
    ) -> Option<Pixels> {
        self.resolved_measured_change_tracking_section_height(resize_handle_h)
            .map(|section_height| (section_height - resize_handle_h).max(px(0.0)))
            .or_else(|| {
                self.current_change_tracking_stack_bounds()
                    .map(|bounds| (bounds.size.height - resize_handle_h).max(px(0.0)))
            })
    }

    pub(in super::super) fn sanitized_restored_change_tracking_height_design(
        view: ChangeTrackingView,
        height: Option<u32>,
    ) -> Option<f32> {
        let min_height: f32 = min_change_tracking_stack_height(
            view == ChangeTrackingView::SplitUntracked,
            px(PANE_RESIZE_HANDLE_PX),
        )
        .into();
        height.map(|value| (value as f32).max(min_height))
    }

    #[cfg(test)]
    pub(in super::super) fn sanitized_restored_change_tracking_height(
        view: ChangeTrackingView,
        height: Option<u32>,
    ) -> Option<Pixels> {
        Self::sanitized_restored_change_tracking_height_design(view, height).map(px)
    }

    pub(in super::super) fn sanitized_restored_untracked_height_design(
        height: Option<u32>,
    ) -> Option<f32> {
        height.map(|value| (value as f32).max(STATUS_SECTION_MIN_HEIGHT_PX))
    }

    #[cfg(test)]
    pub(in super::super) fn sanitized_restored_untracked_height(
        height: Option<u32>,
    ) -> Option<Pixels> {
        Self::sanitized_restored_untracked_height_design(height).map(px)
    }

    fn status_resize_total_height(
        &self,
        handle: StatusSectionResizeHandle,
        resize_handle_h: Pixels,
    ) -> Option<Pixels> {
        match handle {
            StatusSectionResizeHandle::ChangeTrackingAndStaged => {
                self.measured_status_sections_total_height(resize_handle_h)
            }
            StatusSectionResizeHandle::UntrackedAndUnstaged => {
                self.resolved_measured_change_tracking_stack_total_height(resize_handle_h)
            }
        }
    }

    fn start_status_section_resize(
        &mut self,
        handle: StatusSectionResizeHandle,
        start_y: Pixels,
        cx: &mut gpui::Context<Self>,
    ) {
        let section_min_h = px(STATUS_SECTION_MIN_HEIGHT_PX);
        let resize_handle_h = px(PANE_RESIZE_HANDLE_PX);
        let total_height = self.status_resize_total_height(handle, resize_handle_h);
        let start_height = match handle {
            StatusSectionResizeHandle::ChangeTrackingAndStaged => total_height
                .map(|total_height| {
                    resolved_vertical_split_height(
                        self.change_tracking_height,
                        total_height,
                        min_change_tracking_stack_height(
                            self.change_tracking_view == ChangeTrackingView::SplitUntracked,
                            resize_handle_h,
                        ),
                        section_min_h,
                    )
                })
                .or(self.change_tracking_height)
                .unwrap_or(section_min_h),
            StatusSectionResizeHandle::UntrackedAndUnstaged => total_height
                .map(|total_height| {
                    resolved_vertical_split_height(
                        self.untracked_height,
                        total_height,
                        section_min_h,
                        section_min_h,
                    )
                })
                .or(self.untracked_height)
                .unwrap_or(section_min_h),
        };

        self.status_section_resize = Some(StatusSectionResizeState {
            handle,
            start_y,
            start_height,
        });
        cx.notify();
    }

    pub(in super::super) fn update_status_section_resize(
        &mut self,
        current_y: Pixels,
        cx: &mut gpui::Context<Self>,
    ) -> bool {
        let Some(state) = self.status_section_resize else {
            return false;
        };

        let section_min_h = px(STATUS_SECTION_MIN_HEIGHT_PX);
        let resize_handle_h = px(PANE_RESIZE_HANDLE_PX);
        let total_height = self.status_resize_total_height(state.handle, resize_handle_h);

        let delta_y = current_y - state.start_y;
        let mut changed = false;
        match state.handle {
            StatusSectionResizeHandle::ChangeTrackingAndStaged => {
                let min_top = min_change_tracking_stack_height(
                    self.change_tracking_view == ChangeTrackingView::SplitUntracked,
                    resize_handle_h,
                );
                let next_height = if let Some(total_height) = total_height {
                    clamp_vertical_split_height(
                        state.start_height + delta_y,
                        total_height,
                        min_top,
                        section_min_h,
                    )
                } else {
                    (state.start_height + delta_y).max(min_top)
                };
                if self.change_tracking_height != Some(next_height) {
                    self.set_change_tracking_height_from_pixels(Some(next_height));
                    changed = true;
                }
            }
            StatusSectionResizeHandle::UntrackedAndUnstaged => {
                let next_height = if let Some(total_height) = total_height {
                    clamp_vertical_split_height(
                        state.start_height + delta_y,
                        total_height,
                        section_min_h,
                        section_min_h,
                    )
                } else {
                    (state.start_height + delta_y).max(section_min_h)
                };
                if self.untracked_height != Some(next_height) {
                    self.set_untracked_height_from_pixels(Some(next_height));
                    changed = true;
                }
            }
        }

        if changed {
            cx.notify();
        }
        changed
    }

    pub(in super::super) fn finish_status_section_resize(
        &mut self,
        cx: &mut gpui::Context<Self>,
    ) -> bool {
        if self.status_section_resize.take().is_some() {
            let pane = cx.entity();
            self.schedule_ui_settings_persist(cx);
            cx.notify();
            cx.defer(move |cx| {
                pane.update(cx, |_this, cx| {
                    cx.notify();
                });
            });
            true
        } else {
            false
        }
    }

    fn repo_has_head_commit(repo: &RepoState) -> bool {
        if repo.detached_head_commit.is_some() {
            return true;
        }

        match &repo.head_branch {
            Loadable::Ready(head) if head == "HEAD" => true,
            Loadable::Ready(head) => match &repo.branches {
                Loadable::Ready(branches) => branches.iter().any(|branch| branch.name == *head),
                _ => true,
            },
            _ => true,
        }
    }

    fn can_submit_commit(repo: Option<&RepoState>, message: &str, amend: bool) -> bool {
        let Some(repo) = repo else {
            return false;
        };
        if repo.commit_in_flight > 0 {
            return false;
        }
        if message.trim().is_empty() {
            return false;
        }
        if amend {
            return !merge_active(Some(repo))
                && !matches!(repo.rebase_in_progress, Loadable::Ready(true))
                && Self::repo_has_head_commit(repo);
        }
        let staged_count = repo
            .staged_status_entries()
            .map_or(0, |entries| entries.len());
        let is_merge_active = merge_active(Some(repo));
        commit_allowed(is_merge_active, staged_count)
    }

    fn submit_commit(&mut self, cx: &mut gpui::Context<Self>) -> bool {
        let Some(repo_id) = self.active_repo_id() else {
            return false;
        };
        let message = self
            .commit_message_input
            .read_with(cx, |input, _| input.text().to_string());
        let amend = self.commit_amend_enabled;
        if !Self::can_submit_commit(self.active_repo(), &message, amend) {
            return false;
        }

        if amend {
            self.mark_pending_commit_amend(repo_id);
            self.store.dispatch(Msg::CommitAmend {
                repo_id,
                message: message.trim().to_string(),
                push_after_commit: self.commit_push_after_enabled,
            });
        } else {
            self.store.dispatch(Msg::Commit {
                repo_id,
                message: message.trim().to_string(),
                push_after_commit: self.commit_push_after_enabled,
            });
        }
        self.commit_message_programmatic_change = true;
        self.commit_message_input
            .update(cx, |input, cx| input.set_text(String::new(), cx));
        self.commit_message_scroll
            .set_offset(point(px(0.0), px(0.0)));
        cx.notify();
        true
    }

    pub(in super::super) fn handle_commit_submit_shortcut(
        &mut self,
        window: &Window,
        cx: &mut gpui::Context<Self>,
    ) -> bool {
        if !self
            .commit_message_input
            .read(cx)
            .focus_handle()
            .is_focused(window)
        {
            return false;
        }

        let _ = self.submit_commit(cx);
        true
    }

    fn sync_commit_details_input_value(
        input: &Entity<components::TextInput>,
        value: &str,
        cx: &mut gpui::Context<Self>,
    ) {
        if input.read(cx).text() != value {
            input.update(cx, |input, cx| {
                input.set_text(value.to_string(), cx);
            });
        }
    }

    fn sync_commit_details_message_input(
        &mut self,
        message: &str,
        theme: AppTheme,
        repo_id: RepoId,
        cx: &mut gpui::Context<Self>,
    ) {
        let (mut highlights, links) = commit_message_link_highlights(message, theme);
        let mut merged = commit_message_summary_highlights(message, theme, &highlights);
        merged.append(&mut highlights);
        merged.sort_by_key(|(range, _)| range.start);
        self.commit_details_message_input.update(cx, |input, cx| {
            if input.text() != message {
                input.set_text(message.to_string(), cx);
            }
            input.set_highlights(merged, cx);
        });
        self.commit_details_message_link_menu
            .update(cx, |menu, cx| {
                menu.sync(
                    self.commit_details_message_input.clone(),
                    repo_id,
                    links,
                    "commit_details_message_link_menu",
                    cx,
                );
            });
    }

    fn sync_commit_details_parent_input(
        &mut self,
        parent: &str,
        repo_id: RepoId,
        interactive: bool,
        theme: AppTheme,
        cx: &mut gpui::Context<Self>,
    ) {
        Self::sync_commit_details_input_value(&self.commit_details_parent_input, parent, cx);
        self.commit_details_parent_input.update(cx, |input, cx| {
            input.set_highlights(commit_sha_field_highlights(parent, theme), cx);
        });
        let parent_links = commit_sha_field_links(parent, interactive, true);
        self.commit_details_parent_link_menu.update(cx, |menu, cx| {
            menu.sync(
                self.commit_details_parent_input.clone(),
                repo_id,
                parent_links,
                "commit_details_parent_link_menu",
                cx,
            );
        });
    }

    fn sync_commit_details_sha_menu(
        &mut self,
        sha: &str,
        repo_id: RepoId,
        interactive: bool,
        theme: AppTheme,
        cx: &mut gpui::Context<Self>,
    ) {
        Self::sync_commit_details_input_value(&self.commit_details_sha_input, sha, cx);
        self.commit_details_sha_input.update(cx, |input, cx| {
            input.set_highlights(commit_sha_field_highlights(sha, theme), cx);
        });
        // A commit's own SHA has nowhere to navigate to.
        let sha_links = commit_sha_field_links(sha, interactive, false);
        self.commit_details_sha_link_menu.update(cx, |menu, cx| {
            menu.sync(
                self.commit_details_sha_input.clone(),
                repo_id,
                sha_links,
                "commit_details_sha_link_menu",
                cx,
            );
        });
    }

    fn sync_retained_commit_details_message_input(
        &mut self,
        message: &str,
        cx: &mut gpui::Context<Self>,
    ) {
        let theme = self.theme;
        self.commit_details_message_input.update(cx, |input, cx| {
            if input.text() != message {
                input.set_text(message.to_string(), cx);
            }
            input.set_highlights(commit_message_summary_highlights(message, theme, &[]), cx);
        });
        self.commit_details_message_link_menu
            .update(cx, |menu, cx| {
                menu.sync(
                    self.commit_details_message_input.clone(),
                    RepoId(0),
                    Arc::<[components::MessageLink]>::from([]),
                    "commit_details_message_link_menu",
                    cx,
                );
            });
    }

    /// Selected commits resolved against the loaded log page, in log order
    /// (youngest first). Ids missing from the page are skipped.
    fn multi_selected_commits_in_log_order(repo: &RepoState) -> Vec<Commit> {
        let selection = &repo.history_state.multi_selection;
        let Loadable::Ready(page) = &repo.log else {
            return Vec::new();
        };
        // Hash the selection first: this runs per frame (twice, and once more
        // per visible row batch) over the whole loaded page, and
        // `CommitMultiSelection::contains` is a linear scan — so a large
        // selection against a large page would be quadratic on every repaint.
        let selected: FxHashSet<&CommitId> = selection.commits.iter().collect();
        page.commits
            .iter()
            .filter(|commit| selected.contains(&commit.id))
            .cloned()
            .collect()
    }

    /// Commits to preview as cards while a two-point comparison is active.
    /// Prefers a multi-selection *only* when it is genuinely multi, because that
    /// is the one case where the selection is what is being compared (its merged
    /// diff). A single leftover selection — every plain history click leaves one
    /// — describes an unrelated commit, so the mark + compare, branch/tag and
    /// working-tree flows derive their endpoints from the range itself, looking
    /// each SHA up in the loaded log so its summary/author/time can be shown.
    /// Ordered newest first (tip before base) to match the log. The working tree
    /// has no commit of its own, so a compare-against-working-tree range yields
    /// a single card.
    fn range_comparison_commits(repo: &RepoState) -> Vec<Commit> {
        if repo.history_state.multi_selection.is_multi() {
            let multi = Self::multi_selected_commits_in_log_order(repo);
            if !multi.is_empty() {
                return multi;
            }
        }
        let Some(range) = repo.history_state.range_selection.as_ref() else {
            return Vec::new();
        };
        let Loadable::Ready(page) = &repo.log else {
            return Vec::new();
        };
        let find = |id: &CommitId| page.commits.iter().find(|c| &c.id == id).cloned();
        let mut commits = Vec::new();
        if let Some(to) = range.to.as_ref().and_then(&find) {
            commits.push(to);
        }
        if let Some(from) = find(&range.from) {
            commits.push(from);
        }
        commits
    }

    /// One selected/compared-commit preview card: avatar, summary, an author +
    /// relative-time line, and the short SHA. Shared by the multi-selection and
    /// range-comparison lists so both read identically.
    fn commit_card_element(
        &self,
        ix: usize,
        commit: &Commit,
        now: std::time::SystemTime,
        show_border: bool,
    ) -> AnyElement {
        let theme = self.theme;
        let ui_scale = self.ui_scale();
        let scaled_px =
            |value: f32| crate::ui_scale::design_px_from_percent(value, self.ui_scale_percent);

        let short_sha: SharedString = commit
            .id
            .as_ref()
            .get(0..8)
            .unwrap_or(commit.id.as_ref())
            .to_string()
            .into();
        let summary: SharedString = commit.summary.to_string().into();
        let author = commit.author.to_string();
        let unix_secs = commit
            .time
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_secs() as i64)
            .unwrap_or(0);
        let when: SharedString = format!(
            "{} · {}",
            author,
            crate::view::date_time::format_relative_time(unix_secs, now)
        )
        .into();

        div()
            .id(("commit_multi_row", ix))
            .debug_selector(move || format!("commit_multi_row_{ix}"))
            .h(scaled_px(MULTI_COMMIT_ROW_HEIGHT_PX))
            .w_full()
            .flex()
            .items_center()
            .gap(scaled_px(8.0))
            .px(scaled_px(8.0))
            // The last card sits directly above the files section's own top
            // separator, so it omits its bottom border to avoid a double line.
            .when(show_border, |row| {
                row.border_b_1().border_color(theme.colors.stroke.default)
            })
            .child(components::author_avatar(theme, ui_scale, &author))
            .child(
                div()
                    .flex_1()
                    .min_w(px(0.0))
                    .flex()
                    .flex_col()
                    .gap(scaled_px(2.0))
                    .child(div().text_sm().line_clamp(1).child(summary))
                    .child(
                        div()
                            .text_xs()
                            .text_color(theme.colors.foreground.secondary)
                            .line_clamp(1)
                            .child(when),
                    ),
            )
            .child(
                div()
                    .flex_none()
                    .text_xs()
                    .font_family(crate::view::UI_MONOSPACE_FONT_FAMILY)
                    .text_color(theme.colors.foreground.secondary)
                    .child(short_sha),
            )
            .into_any_element()
    }

    /// Rows for both the comparison view's endpoint cards and the plain
    /// multi-selection list. `range_comparison_commits` already resolves to the
    /// multi-selection when that is what is being compared, so one renderer
    /// serves both and the two views cannot drift apart.
    pub(in super::super) fn render_multi_commit_rows(
        this: &mut Self,
        range: Range<usize>,
        _window: &mut Window,
        cx: &mut gpui::Context<Self>,
    ) -> Vec<AnyElement> {
        let _ = cx;
        let Some(repo) = this.active_repo() else {
            return Vec::new();
        };
        let commits = Self::range_comparison_commits(repo);
        let last_ix = commits.len().saturating_sub(1);
        let now = std::time::SystemTime::now();
        range
            .filter_map(|ix| commits.get(ix).map(|commit| (ix, commit.clone())))
            .map(|(ix, commit)| this.commit_card_element(ix, &commit, now, ix != last_ix))
            .collect()
    }

    /// The details pane's standard vertical-scroll frame: the list fills the
    /// container, a gutter reserves room for the scrollbar so rows never sit
    /// underneath it, and the scrollbar overlays the right edge. Every scrolling
    /// list in this pane is built from this, so they all scroll alike.
    fn vertical_scroll_frame(
        theme: AppTheme,
        container_id: impl Into<ElementId>,
        scrollbar_id: impl Into<ElementId>,
        scroll: &UniformListScrollHandle,
        list: gpui::UniformList,
    ) -> Stateful<Div> {
        let list = restrict_scroll_to_vertical_axis(
            list.w_full().h_full().min_h(px(0.0)).track_scroll(scroll),
        );
        let scrollbar_gutter = components::Scrollbar::visible_gutter(
            scroll.clone(),
            components::ScrollbarAxis::Vertical,
        );
        div()
            .id(container_id)
            .relative()
            .flex()
            .flex_col()
            .flex_1()
            .h_full()
            .min_h(px(0.0))
            .w_full()
            .overflow_hidden()
            .child(
                div()
                    .w_full()
                    .flex_1()
                    .h_full()
                    .min_h(px(0.0))
                    .pr(scrollbar_gutter)
                    .child(list),
            )
            .child(components::Scrollbar::new(scrollbar_id, scroll.clone()).render(theme))
    }

    /// The scrolling column of commit preview cards, shared by the plain
    /// multi-selection view (where it fills the pane) and the comparison view
    /// (where it is capped and sits above the changed-file list).
    fn commit_cards_list(
        &mut self,
        repo_id: RepoId,
        count: usize,
        cx: &mut gpui::Context<Self>,
    ) -> Stateful<Div> {
        Self::vertical_scroll_frame(
            self.theme,
            ("commit_multi_container", repo_id.0),
            ("commit_multi_scrollbar", repo_id.0),
            &self.commit_multi_scroll,
            uniform_list(
                ("commit_multi_list", repo_id.0),
                count,
                cx.processor(Self::render_multi_commit_rows),
            ),
        )
    }

    fn multi_commit_details_view(
        &mut self,
        repo_id: RepoId,
        count: usize,
        cx: &mut gpui::Context<Self>,
    ) -> AnyElement {
        let theme = self.theme;
        let ui_scale = self.ui_scale();

        let header = div()
            .flex()
            .items_center()
            .justify_between()
            .h(components::control_height_md(ui_scale))
            .px_2()
            .bg(theme.colors.surface.raised)
            .border_b_1()
            .border_color(theme.colors.stroke.default)
            .child(
                div()
                    .flex_1()
                    .min_w(px(0.0))
                    .text_sm()
                    .font_weight(FontWeight::BOLD)
                    .line_clamp(1)
                    .child(SharedString::from(format!("{count} commits selected"))),
            )
            .child(
                components::Button::new("commit_details_close", "")
                    .start_slot(svg_icon(
                        "icons/generic_close.svg",
                        theme.colors.foreground.secondary,
                        px(12.0),
                    ))
                    .style(components::ButtonStyle::Transparent)
                    .on_click(theme, cx, |this, _e, _w, cx| {
                        if let Some(repo_id) = this.active_repo_id() {
                            this.store.dispatch(Msg::ClearCommitSelection { repo_id });
                        }
                        cx.notify();
                    })
                    .gitcomet_tooltip(theme, "Close commit details".into()),
            );

        let body = self.commit_cards_list(repo_id, count, cx);

        div()
            .id("commit_details_container")
            .relative()
            .flex()
            .flex_col()
            .flex_1()
            .h_full()
            .min_h(px(0.0))
            .child(header)
            .child(
                div()
                    .id("commit_details_body_container")
                    .relative()
                    .flex()
                    .flex_col()
                    .flex_1()
                    .h_full()
                    .min_h(px(0.0))
                    .p_2()
                    .child(body),
            )
            .into_any_element()
    }

    /// The changed files of a linked worktree that is *not* this tab, shown when
    /// its history row is selected.
    ///
    /// The worktree chip is the header rather than decoration: everything below
    /// belongs to another checkout, and nothing else on screen says so.
    fn worktree_uncommitted_view(
        &mut self,
        repo_id: RepoId,
        cx: &mut gpui::Context<Self>,
    ) -> AnyElement {
        let theme = self.theme;
        let ui_scale = self.ui_scale();

        // Only the counts and the chip's three fields are needed here. Cloning the
        // summary would copy both `FileStatus` vectors -- every changed *and*
        // untracked file of the worktree -- on every repaint of this pane.
        let Some((file_count, loaded_file_count, chip_label, worktree_path)) =
            self.selected_worktree_summary().map(|summary| {
                (
                    // Counts, not `staged.len() + unstaged.len()`: the file lists
                    // arrive with the scan the selection asked for, and the header
                    // has to be right before then. Each changed file lands in
                    // exactly one bucket, so the two agree once loaded.
                    summary.added + summary.modified + summary.deleted,
                    summary.staged.len() + summary.unstaged.len(),
                    crate::view::rows::sidebar::worktree_origin_label(
                        summary.branch.as_deref(),
                        summary.detached,
                        &summary.path,
                    ),
                    summary.path.clone(),
                )
            })
        else {
            return div().into_any_element();
        };

        let header = div()
            .flex()
            .items_center()
            .gap_2()
            .h(components::control_height_md(ui_scale))
            .px_2()
            .bg(theme.colors.surface.raised)
            .border_b_1()
            .border_color(theme.colors.stroke.default)
            .child(
                div()
                    .flex_none()
                    .text_sm()
                    .font_weight(FontWeight::BOLD)
                    // Not "Uncommitted changes": that is the current repo's
                    // own row, and these are somebody else's.
                    .child(SharedString::from("Worktree changes")),
            )
            .child(div().flex_1().min_w(px(0.0)))
            .child({
                let open_path = worktree_path.clone();
                let palette = crate::view::rows::sidebar::worktree_badge_palette(theme);
                crate::view::rows::sidebar::worktree_origin_chip(
                    theme,
                    chip_label,
                    ui_scale.px(10.0),
                    ui_scale.px(18.0),
                    ui_scale.px(220.0),
                    ui_scale.px(6.0),
                )
                .id("worktree_uncommitted_origin")
                .debug_selector(|| "worktree_uncommitted_open".to_string())
                .cursor(CursorStyle::PointingHand)
                .hover(move |s| {
                    s.border_color(palette.hover_border)
                        .text_color(palette.hover_text)
                })
                .gitcomet_tooltip(
                    theme,
                    format!("Open this worktree in a tab\n{}", worktree_path.display()).into(),
                )
                // A chip is a control of its own: a right or middle click must not
                // open a repo tab, and a left click must not reach the row behind it.
                .on_click(cx.listener(move |this, e: &ClickEvent, _w, cx| {
                    if !e.standard_click() {
                        return;
                    }
                    cx.stop_propagation();
                    this.store.dispatch(Msg::OpenRepo(open_path.clone()));
                    cx.notify();
                }))
            })
            .child(
                components::Button::new("worktree_uncommitted_close", "")
                    .start_slot(svg_icon(
                        "icons/generic_close.svg",
                        theme.colors.foreground.secondary,
                        px(12.0),
                    ))
                    .style(components::ButtonStyle::Transparent)
                    .on_click(theme, cx, move |this, _e, _w, cx| {
                        this.store.dispatch(Msg::ClearCommitSelection { repo_id });
                        cx.notify();
                    })
                    .gitcomet_tooltip(theme, "Close".into()),
            );

        // No "no files" state: the scan only reports a worktree once
        // `WorktreeDirtySummary::is_dirty` holds, so `file_count` -- the sum of
        // those same three counts -- is always positive here. A change that
        // started reporting clean worktrees would need a branch of its own; it
        // would otherwise sit on "Loading files…" forever.
        let files_body: AnyElement = if loaded_file_count == 0 {
            // Counts without files means the scan carrying them is still running.
            // Saying so beats an empty list that reads as "nothing changed" while
            // the header above it counts the changes.
            div()
                .debug_selector(|| "worktree_files_loading".to_string())
                .text_sm()
                .text_color(theme.colors.foreground.secondary)
                .child("Loading files…")
                .into_any_element()
        } else {
            Self::vertical_scroll_frame(
                theme,
                ("worktree_files_container", repo_id.0),
                ("worktree_files_scrollbar", repo_id.0),
                &self.worktree_files_scroll,
                uniform_list(
                    ("worktree_files_list", repo_id.0),
                    loaded_file_count,
                    cx.processor(Self::render_worktree_file_rows),
                ),
            )
            .into_any_element()
        };

        div()
            .id("worktree_uncommitted_container")
            .relative()
            .flex()
            .flex_col()
            .flex_1()
            .h_full()
            .min_h(px(0.0))
            .child(header)
            .child(
                div()
                    .id("worktree_uncommitted_body")
                    .debug_selector(|| "worktree_uncommitted_body".to_string())
                    .relative()
                    .flex()
                    .flex_col()
                    .gap_1()
                    .flex_1()
                    .h_full()
                    .min_h(px(0.0))
                    .p_2()
                    .child(
                        div()
                            .text_sm()
                            .text_color(theme.colors.foreground.secondary)
                            .line_clamp(1)
                            .child(SharedString::from(format!("{file_count} changed"))),
                    )
                    .child(
                        div()
                            .flex()
                            .flex_col()
                            .gap_1()
                            .flex_1()
                            .h_full()
                            .min_h(ui_scale.px(RANGE_FILES_SECTION_MIN_HEIGHT_PX))
                            .border_t_1()
                            .border_color(theme.colors.stroke.subtle)
                            .pt_2()
                            .child(files_body),
                    ),
            )
            .into_any_element()
    }

    /// The details-pane view shown while two points are being compared: the
    /// selected commit cards, a "viewing diff between" subheader, and the list
    /// of files that differ between them. The diff pane starts empty; clicking a
    /// file loads that file's range diff in the main pane.
    fn range_comparison_view(
        &mut self,
        repo_id: RepoId,
        cx: &mut gpui::Context<Self>,
    ) -> AnyElement {
        let theme = self.theme;
        let ui_scale = self.ui_scale();

        /// What the files section has to say for itself, kept separate from the
        /// count so a failed load can't read as an empty comparison.
        enum RangeFilesState {
            Loading,
            Failed(String),
            Loaded(usize),
        }

        let (card_count, is_merged_selection, range, files_state) = {
            let Some(repo) = self.active_repo() else {
                return div().into_any_element();
            };
            let Some(range) = repo.history_state.range_selection.clone() else {
                return div().into_any_element();
            };
            let card_count = Self::range_comparison_commits(repo).len();
            // Only a genuine multi-selection is a "merged diff of N commits";
            // every other flow compares two named points, however many of them
            // happen to resolve to a card.
            let is_merged_selection = repo.history_state.multi_selection.is_multi();
            let files_state = match &repo.history_state.range_files {
                Loadable::Ready(files) => RangeFilesState::Loaded(files.len()),
                Loadable::Error(e) => RangeFilesState::Failed(e.clone()),
                Loadable::Loading | Loadable::NotLoaded => RangeFilesState::Loading,
            };
            (card_count, is_merged_selection, range, files_state)
        };

        let header_title: SharedString = if is_merged_selection {
            format!("{card_count} commits selected").into()
        } else {
            "Comparison".into()
        };
        let subheader: SharedString = if is_merged_selection {
            format!("Viewing merged diff of {card_count} commits").into()
        } else {
            format!("Viewing diff: {} → {}", range.from_label, range.to_label).into()
        };

        let header = div()
            .flex()
            .items_center()
            .justify_between()
            .h(components::control_height_md(ui_scale))
            .px_2()
            .bg(theme.colors.surface.raised)
            .border_b_1()
            .border_color(theme.colors.stroke.default)
            .child(
                div()
                    .flex_1()
                    .min_w(px(0.0))
                    .text_sm()
                    .font_weight(FontWeight::BOLD)
                    .line_clamp(1)
                    .child(header_title),
            )
            .child(
                components::Button::new("range_comparison_close", "")
                    .start_slot(svg_icon(
                        "icons/generic_close.svg",
                        theme.colors.foreground.secondary,
                        px(12.0),
                    ))
                    .style(components::ButtonStyle::Transparent)
                    .on_click(theme, cx, |this, _e, _w, cx| {
                        if let Some(repo_id) = this.active_repo_id() {
                            this.store.dispatch(Msg::ClearComparison { repo_id });
                        }
                        cx.notify();
                    })
                    .gitcomet_tooltip(theme, "Close comparison".into()),
            );

        // Compared-commit preview cards. A two-point comparison has one or two,
        // but a multi-selection has one per selected commit, so the section
        // grows with the selection only up to half the comparison body and
        // scrolls past that — an even split with the changed-file list below,
        // rather than crowding it out.
        //
        // The cap is relative to the body, so it tracks the pane at whatever
        // height the splitter leaves it. The requested height stays definite
        // though: the card list is a `uniform_list`, which paints nothing when
        // its viewport height is indefinite, so `max_h` does the capping rather
        // than the height itself being content-derived.
        let card_row_height = ui_scale.px(MULTI_COMMIT_ROW_HEIGHT_PX);
        let cards = (card_count > 0).then(|| {
            div()
                .debug_selector(|| "range_comparison_cards".to_string())
                .flex()
                .flex_col()
                .w_full()
                .h(card_row_height * card_count as f32)
                .max_h(relative(COMPARISON_CARDS_MAX_BODY_FRACTION))
                .min_h(card_row_height)
                .child(self.commit_cards_list(repo_id, card_count, cx))
        });

        // No count until there is one: claiming "0 changed" while the diff is
        // still running states a number that is usually about to be wrong. The
        // selector names which of the two the label is, so a test can tell them
        // apart without reading painted text.
        let (files_label, files_label_selector): (SharedString, &'static str) = match &files_state {
            RangeFilesState::Loading | RangeFilesState::Failed(_) => {
                ("Changed files".into(), "range_files_label_pending")
            }
            RangeFilesState::Loaded(count) => {
                (format!("{count} changed").into(), "range_files_label_count")
            }
        };
        let files_body: AnyElement = match &files_state {
            RangeFilesState::Loading => div()
                .debug_selector(|| "range_files_loading".to_string())
                .text_sm()
                .text_color(theme.colors.foreground.secondary)
                .child("Loading")
                .into_any_element(),
            // An error must not render as "No files." — that is exactly what a
            // pair of identical commits looks like, so the user would read a
            // failed comparison as a successful, empty one.
            RangeFilesState::Failed(message) => div()
                .debug_selector(|| "range_files_error".to_string())
                .text_sm()
                .text_color(theme.colors.status.danger.foreground)
                .child(SharedString::from(message.clone()))
                .into_any_element(),
            RangeFilesState::Loaded(0) => div()
                .debug_selector(|| "range_files_empty".to_string())
                .text_sm()
                .text_color(theme.colors.foreground.secondary)
                .child("No files.")
                .into_any_element(),
            RangeFilesState::Loaded(count) => Self::vertical_scroll_frame(
                theme,
                ("range_files_container", repo_id.0),
                ("range_files_scrollbar", repo_id.0),
                &self.range_files_scroll,
                uniform_list(
                    ("range_files_list", repo_id.0),
                    *count,
                    cx.processor(Self::render_range_file_rows),
                ),
            )
            .into_any_element(),
        };

        div()
            .id("range_comparison_container")
            .relative()
            .flex()
            .flex_col()
            .flex_1()
            .h_full()
            .min_h(px(0.0))
            .child(header)
            .child(
                div()
                    .id("range_comparison_body")
                    .debug_selector(|| "range_comparison_body".to_string())
                    .relative()
                    .flex()
                    .flex_col()
                    .gap_2()
                    .flex_1()
                    .h_full()
                    .min_h(px(0.0))
                    .p_2()
                    .child(
                        div()
                            .text_sm()
                            .text_color(theme.colors.foreground.secondary)
                            .line_clamp(1)
                            .child(subheader),
                    )
                    .children(cards)
                    .child(
                        div()
                            .debug_selector(|| "range_comparison_files".to_string())
                            .flex()
                            .flex_col()
                            .gap_1()
                            .flex_1()
                            .h_full()
                            // A real floor, not `px(0.0)`: the cards above are
                            // sized from the window, so on a pane shorter than
                            // that this is what stops them from taking the whole
                            // pane and collapsing the list to nothing.
                            .min_h(ui_scale.px(RANGE_FILES_SECTION_MIN_HEIGHT_PX))
                            .border_t_1()
                            .border_color(theme.colors.stroke.subtle)
                            .pt_2()
                            .child(
                                div()
                                    .debug_selector(move || files_label_selector.to_string())
                                    .text_sm()
                                    .text_color(theme.colors.foreground.secondary)
                                    .child(files_label),
                            )
                            .child(files_body),
                    ),
            )
            .into_any_element()
    }

    /// Commit message shown in the details pane: a scrollable block whose
    /// summary line is emphasized and whose SHA references are linkified.
    fn commit_details_message_view(&self, theme: AppTheme, repo_id: RepoId) -> AnyElement {
        components::ScrollContainer::vertical(
            ("commit_details_message_scroll_surface", repo_id.0),
            ("commit_details_message_scrollbar", repo_id.0),
            self.commit_scroll.clone(),
            px(COMMIT_DETAILS_MESSAGE_MAX_HEIGHT_PX),
        )
        .container_id(("commit_details_message_container", repo_id.0))
        .debug_selector("commit_details_message_scroll_surface")
        .render(theme, self.commit_details_message_link_menu.clone())
    }

    pub(in super::super) fn commit_details_view(
        &mut self,
        cx: &mut gpui::Context<Self>,
    ) -> AnyElement {
        let theme = self.theme;
        let ui_scale = self.ui_scale();
        let commit_files_min_viewport_height = ui_scale.px(24.0);
        let commit_files_section_min_height = ui_scale.px(44.0);
        let active_repo_id = self.active_repo_id();
        let selected_id = self
            .active_repo()
            .and_then(|repo| repo.history_state.selected_commit.clone());

        // A selected worktree row owns the pane outright: its files belong to a
        // different checkout, so none of the commit-detail views below apply.
        //
        // Only while its scan entry is actually there, though. The reducer drops
        // the selection when the worktree goes clean, but a scan that is still in
        // flight (or that failed) leaves the selection pointing at nothing for a
        // frame or two, and this view has nothing to render without it.
        let has_worktree_selection = self.selected_worktree_summary().is_some();
        if let (Some(repo_id), true) = (active_repo_id, has_worktree_selection) {
            return self.worktree_uncommitted_view(repo_id, cx);
        }

        // An active two-point comparison takes precedence over both the single
        // and multi commit-detail views: show the range's changed files.
        let has_range_comparison = self
            .active_repo()
            .is_some_and(|repo| repo.history_state.range_selection.is_some());
        if let (Some(repo_id), true) = (active_repo_id, has_range_comparison) {
            return self.range_comparison_view(repo_id, cx);
        }

        let multi_count = self
            .active_repo()
            .filter(|repo| repo.history_state.multi_selection.is_multi())
            .map(Self::multi_selected_commits_in_log_order)
            .filter(|commits| commits.len() > 1)
            .map(|commits| commits.len());
        if let (Some(repo_id), Some(count)) = (active_repo_id, multi_count) {
            return self.multi_commit_details_view(repo_id, count, cx);
        }

        if let (Some(repo_id), Some(selected_id)) = (active_repo_id, selected_id) {
            let show_delayed_loading = self.commit_details_delay.as_ref().is_some_and(|s| {
                s.repo_id == repo_id && s.commit_id == selected_id && s.show_loading
            });

            let header_title: SharedString = "Commit details".into();

            let header = div()
                .flex()
                .items_center()
                .justify_between()
                .h(components::control_height_md(ui_scale))
                .px_2()
                .child(
                    div()
                        .flex_1()
                        .min_w(px(0.0))
                        .text_sm()
                        .font_weight(FontWeight::BOLD)
                        .line_clamp(1)
                        .child(header_title),
                )
                .child(
                    components::Button::new("commit_details_close", "")
                        .start_slot(svg_icon(
                            "icons/generic_close.svg",
                            theme.colors.foreground.secondary,
                            px(12.0),
                        ))
                        .style(components::ButtonStyle::Transparent)
                        .on_click(theme, cx, |this, _e, _w, cx| {
                            // The commit details and diff views are independent
                            // panels; closing details must not close the diff.
                            if let Some(repo_id) = this.active_repo_id() {
                                this.store.dispatch(Msg::ClearCommitSelection { repo_id });
                            }
                            cx.notify();
                        })
                        .gitcomet_tooltip(theme, "Close commit details".into()),
                );

            let active_commit_details = self.active_repo().map(|repo| {
                (
                    repo.history_state.commit_details.clone(),
                    repo.history_state.commit_details_rev,
                )
            });
            let body: AnyElement = match active_commit_details.as_ref().map(|(details, _)| details)
            {
                None => {
                    components::empty_state(theme, "Commit", "No repository.").into_any_element()
                }
                Some(Loadable::Loading) => {
                    if show_delayed_loading {
                        components::empty_state(theme, "Commit", "Loading").into_any_element()
                    } else {
                        div().into_any_element()
                    }
                }
                Some(Loadable::Error(e)) => {
                    components::empty_state(theme, "Commit", e.clone()).into_any_element()
                }
                Some(Loadable::NotLoaded) => {
                    if show_delayed_loading {
                        components::empty_state(theme, "Commit", "Loading").into_any_element()
                    } else {
                        div().into_any_element()
                    }
                }
                Some(Loadable::Ready(details)) => {
                    if details.id != selected_id {
                        if show_delayed_loading {
                            components::empty_state(theme, "Commit", "Loading").into_any_element()
                        } else {
                            let parent = details
                                .parent_ids
                                .first()
                                .map(|p: &CommitId| p.as_ref().to_string())
                                .unwrap_or_else(|| "—".to_string());

                            let files = if details.files.is_empty() {
                                div()
                                    .text_sm()
                                    .text_color(theme.colors.foreground.secondary)
                                    .child("No files.")
                                    .into_any_element()
                            } else {
                                Self::vertical_scroll_frame(
                                    theme,
                                    ("commit_details_files_container", repo_id.0),
                                    ("commit_details_files_scrollbar", repo_id.0),
                                    &self.commit_files_scroll,
                                    uniform_list(
                                        ("commit_details_files_list", repo_id.0),
                                        details.files.len(),
                                        cx.processor(Self::render_commit_file_rows),
                                    ),
                                )
                                .min_h(commit_files_min_viewport_height)
                                .into_any_element()
                            };

                            self.sync_retained_commit_details_message_input(
                                details.message.as_str(),
                                cx,
                            );
                            Self::sync_commit_details_input_value(
                                &self.commit_details_sha_input,
                                details.id.as_ref(),
                                cx,
                            );
                            Self::sync_commit_details_input_value(
                                &self.commit_details_date_input,
                                self.commit_details_date_display(details).as_str(),
                                cx,
                            );
                            self.sync_commit_details_parent_input(
                                parent.as_str(),
                                RepoId(0),
                                false,
                                theme,
                                cx,
                            );

                            let message = self.commit_details_message_view(theme, repo_id);

                            div()
                                .flex()
                                .flex_col()
                                .gap_2()
                                .flex_1()
                                .h_full()
                                .min_h(px(0.0))
                                .child(
                                    div()
                                        .flex()
                                        .flex_col()
                                        .gap_2()
                                        .w_full()
                                        .min_w(px(0.0))
                                        .child(message)
                                        .children(commit_details_author_row(
                                            theme, ui_scale, details,
                                        ))
                                        .child(commit_details_selectable_row(
                                            theme,
                                            "Commit SHA",
                                            commit_details_monospace_value(
                                                self.commit_details_sha_input.clone(),
                                            ),
                                        ))
                                        .child(commit_details_selectable_row(
                                            theme,
                                            "Commit date",
                                            commit_details_monospace_value(
                                                self.commit_details_date_input.clone(),
                                            ),
                                        ))
                                        .child(commit_details_selectable_row(
                                            theme,
                                            "Parent commit SHA",
                                            commit_details_monospace_value(
                                                self.commit_details_parent_input.clone(),
                                            ),
                                        )),
                                )
                                .child(
                                    div()
                                        .flex()
                                        .flex_col()
                                        .gap_1()
                                        .flex_1()
                                        .h_full()
                                        .min_h(commit_files_section_min_height)
                                        .border_t_1()
                                        .border_color(theme.colors.stroke.subtle)
                                        .pt_2()
                                        .child(
                                            div()
                                                .text_sm()
                                                .text_color(theme.colors.foreground.secondary)
                                                .child(format!(
                                                    "Committed files ({})",
                                                    details.files.len()
                                                )),
                                        )
                                        .child(files),
                                )
                                .into_any_element()
                        }
                    } else {
                        let parent = details
                            .parent_ids
                            .first()
                            .map(|p: &CommitId| p.as_ref().to_string())
                            .unwrap_or_else(|| "—".to_string());

                        let files = if details.files.is_empty() {
                            div()
                                .text_sm()
                                .text_color(theme.colors.foreground.secondary)
                                .child("No files.")
                                .into_any_element()
                        } else {
                            Self::vertical_scroll_frame(
                                theme,
                                ("commit_details_files_container", repo_id.0),
                                ("commit_details_files_scrollbar", repo_id.0),
                                &self.commit_files_scroll,
                                uniform_list(
                                    ("commit_details_files_list", repo_id.0),
                                    details.files.len(),
                                    cx.processor(Self::render_commit_file_rows),
                                ),
                            )
                            .min_h(commit_files_min_viewport_height)
                            .into_any_element()
                        };

                        self.sync_commit_details_message_input(
                            details.message.as_str(),
                            theme,
                            repo_id,
                            cx,
                        );
                        Self::sync_commit_details_input_value(
                            &self.commit_details_sha_input,
                            details.id.as_ref(),
                            cx,
                        );
                        Self::sync_commit_details_input_value(
                            &self.commit_details_date_input,
                            self.commit_details_date_display(details).as_str(),
                            cx,
                        );
                        self.sync_commit_details_sha_menu(
                            details.id.as_ref(),
                            repo_id,
                            true,
                            theme,
                            cx,
                        );
                        self.sync_commit_details_parent_input(
                            parent.as_str(),
                            repo_id,
                            parent != "—",
                            theme,
                            cx,
                        );

                        let message = self.commit_details_message_view(theme, repo_id);

                        div()
                            .flex()
                            .flex_col()
                            .gap_2()
                            .flex_1()
                            .h_full()
                            .min_h(px(0.0))
                            .child(
                                div()
                                    .flex()
                                    .flex_col()
                                    .gap_2()
                                    .w_full()
                                    .min_w(px(0.0))
                                    .child(message)
                                    .children(commit_details_author_row(theme, ui_scale, details))
                                    .child(commit_details_selectable_row(
                                        theme,
                                        "Commit SHA",
                                        commit_details_monospace_element(
                                            self.commit_details_sha_link_menu
                                                .clone()
                                                .into_any_element(),
                                        ),
                                    ))
                                    .child(commit_details_selectable_row(
                                        theme,
                                        "Commit date",
                                        commit_details_monospace_value(
                                            self.commit_details_date_input.clone(),
                                        ),
                                    ))
                                    .child(commit_details_selectable_row(
                                        theme,
                                        "Parent commit SHA",
                                        commit_details_monospace_element(
                                            self.commit_details_parent_link_menu
                                                .clone()
                                                .into_any_element(),
                                        ),
                                    )),
                            )
                            .child(
                                div()
                                    .flex()
                                    .flex_col()
                                    .gap_1()
                                    .flex_1()
                                    .h_full()
                                    .min_h(commit_files_section_min_height)
                                    .border_t_1()
                                    .border_color(theme.colors.stroke.subtle)
                                    .pt_2()
                                    .child(
                                        div()
                                            .text_sm()
                                            .text_color(theme.colors.foreground.secondary)
                                            .child(format!(
                                                "Committed files ({})",
                                                details.files.len()
                                            )),
                                    )
                                    .child(files),
                            )
                            .into_any_element()
                    }
                }
            };

            return div()
                .id("commit_details_container")
                .relative()
                .flex()
                .flex_col()
                .flex_1()
                .h_full()
                .min_h(px(0.0))
                .child(header)
                .child(
                    div()
                        .id("commit_details_body_container")
                        .relative()
                        .flex()
                        .flex_col()
                        .flex_1()
                        .h_full()
                        .min_h(px(0.0))
                        .p_2()
                        .child(body),
                )
                .into_any_element();
        }

        let local_actions_in_flight = self
            .active_repo()
            .map(|r| r.local_actions_in_flight > 0)
            .unwrap_or(false);
        let (staged_count, unstaged_count, untracked_count, split_unstaged_count) = self
            .active_repo()
            .map(|repo| {
                (
                    StatusSectionEntries::from_repo(repo, StatusSection::Staged)
                        .map_or(0, |entries| entries.len()),
                    StatusSectionEntries::from_repo(repo, StatusSection::CombinedUnstaged)
                        .map_or(0, |entries| entries.len()),
                    StatusSectionEntries::from_repo(repo, StatusSection::Untracked)
                        .map_or(0, |entries| entries.len()),
                    StatusSectionEntries::from_repo(repo, StatusSection::Unstaged)
                        .map_or(0, |entries| entries.len()),
                )
            })
            .unwrap_or((0, 0, 0, 0));
        let (untracked_paths, split_unstaged_paths) = self
            .active_repo()
            .map(|repo| {
                (
                    StatusSectionEntries::from_repo(repo, StatusSection::Untracked)
                        .map_or_else(Vec::new, |entries| entries.path_vec()),
                    StatusSectionEntries::from_repo(repo, StatusSection::Unstaged)
                        .map_or_else(Vec::new, |entries| entries.path_vec()),
                )
            })
            .unwrap_or_else(|| (Vec::new(), Vec::new()));
        let (unstaged_loading, untracked_loading, split_unstaged_loading, staged_loading) = self
            .active_repo()
            .map(|repo| {
                (
                    status_section_is_loading(repo, StatusSection::CombinedUnstaged),
                    status_section_is_loading(repo, StatusSection::Untracked),
                    status_section_is_loading(repo, StatusSection::Unstaged),
                    status_section_is_loading(repo, StatusSection::Staged),
                )
            })
            .unwrap_or((false, false, false, false));

        let repo_id = self.active_repo_id();
        let selected_combined_unstaged = repo_id
            .map(|rid| {
                self.status_section_action_selection(rid, StatusSection::CombinedUnstaged)
                    .count()
            })
            .unwrap_or(0);
        let selected_untracked = repo_id
            .map(|rid| {
                self.status_section_action_selection(rid, StatusSection::Untracked)
                    .count()
            })
            .unwrap_or(0);
        let selected_split_unstaged = repo_id
            .map(|rid| {
                self.status_section_action_selection(rid, StatusSection::Unstaged)
                    .count()
            })
            .unwrap_or(0);
        let selected_staged = repo_id
            .map(|rid| {
                self.status_section_action_selection(rid, StatusSection::Staged)
                    .count()
            })
            .unwrap_or(0);

        let spinner = |id: (&'static str, u64), color: gpui::Rgba| svg_spinner(id, color, px(14.0));
        let repo_key = repo_id.map(|id| id.0).unwrap_or(0);
        let split_change_tracking = self.change_tracking_view == ChangeTrackingView::SplitUntracked;
        let icon_muted = with_alpha(
            theme.colors.accent.foreground,
            if theme.is_dark { 0.72 } else { 0.82 },
        );
        let ui_scale_percent = crate::ui_scale::current(cx).percent;

        // Measured last frame by the probe on the sections container below; the
        // prepaint callback refreshes the window when it changes. Unmeasured on
        // the very first frame, which reads as "plenty of room" and settles on
        // the next one.
        let header_width = self
            .current_status_sections_bounds()
            .map(|bounds| bounds.size.width)
            .unwrap_or(Pixels::MAX);
        let labels_for =
            |title_chars: usize, title_is_dropdown: bool, action_label_chars: &[usize]| {
                status_action_labels_for_width(
                    header_width,
                    title_chars,
                    title_is_dropdown,
                    action_label_chars,
                    local_actions_in_flight,
                    ui_scale_percent,
                )
            };
        let count_chars =
            |word: &str, count: usize| word.chars().count() + 3 + count.to_string().len();
        let unstaged_labels = if selected_combined_unstaged > 0 {
            labels_for(
                "Unstaged".len(),
                true,
                &[
                    count_chars("Stage", selected_combined_unstaged),
                    count_chars("Discard", selected_combined_unstaged),
                    "Stage all changes".len(),
                ],
            )
        } else {
            labels_for("Unstaged".len(), true, &["Stage all changes".len()])
        };
        let untracked_labels = if selected_untracked > 0 {
            labels_for(
                "Untracked".len(),
                true,
                &[
                    count_chars("Stage", selected_untracked),
                    count_chars("Discard", selected_untracked),
                    "Stage all".len(),
                ],
            )
        } else {
            labels_for("Untracked".len(), true, &["Stage all".len()])
        };
        let split_unstaged_labels = if selected_split_unstaged > 0 {
            labels_for(
                "Unstaged".len(),
                true,
                &[
                    count_chars("Stage", selected_split_unstaged),
                    count_chars("Discard", selected_split_unstaged),
                    "Stage all".len(),
                ],
            )
        } else {
            labels_for("Unstaged".len(), true, &["Stage all".len()])
        };
        let staged_labels = if selected_staged > 0 {
            labels_for(
                "Staged".len(),
                false,
                &[
                    count_chars("Unstage", selected_staged),
                    "Unstage all changes".len(),
                ],
            )
        } else {
            labels_for("Staged".len(), false, &["Unstage all changes".len()])
        };

        let stage_all = components::Button::new(
            "stage_all",
            status_action_all_label(unstaged_labels, "Stage all changes"),
        )
        .style(components::ButtonStyle::Subtle)
        .disabled(local_actions_in_flight)
        .on_click(theme, cx, |this, _e, _w, cx| {
            let Some(repo_id) = this.active_repo_id() else {
                return;
            };
            // Empty paths: this button stages every change there is.
            this.stage_all_with_conflict_confirmation(repo_id, Vec::new(), _w, cx);
        })
        .gitcomet_tooltip(theme, "Stage all changes".into());

        let stage_selected = components::Button::new(
            "stage_selected",
            status_action_count_label(unstaged_labels, "Stage", selected_combined_unstaged),
        )
        .style(components::ButtonStyle::Subtle)
        .disabled(local_actions_in_flight)
        .on_click(theme, cx, |this, _e, _w, cx| {
            let Some(repo_id) = this.active_repo_id() else {
                return;
            };
            // Read without consuming: the confirmation below can still be
            // cancelled, and that must leave the selection as the user built it.
            let selection =
                this.status_section_action_selection(repo_id, StatusSection::CombinedUnstaged);
            let paths = selection.paths;
            if paths.is_empty() {
                return;
            }
            if let Some(confirm) = crate::view::conflict_markers::stage_confirm_popover(
                &this.state,
                repo_id,
                paths.clone(),
                selection.from_explicit_selection,
            ) {
                let anchor = crate::view::conflict_markers::centered_dialog_anchor(_w);
                this.open_popover_at(confirm, anchor, _w, cx);
                cx.notify();
                return;
            }
            if selection.from_explicit_selection {
                this.clear_status_multi_selection(repo_id);
            }
            this.store.dispatch(Msg::ClearDiffSelection { repo_id });
            this.store.dispatch(Msg::StagePaths {
                repo_id,
                paths: paths.into(),
            });
            cx.notify();
        })
        .debug_selector(|| "stage_selected_button".to_string())
        .gitcomet_tooltip(
            theme,
            format!(
                "Stage {selected_combined_unstaged} selected {}",
                status_action_file_count(selected_combined_unstaged)
            )
            .into(),
        );

        let discard_selected = components::Button::new(
            "discard_selected",
            status_action_count_label(unstaged_labels, "Discard", selected_combined_unstaged),
        )
        .style(components::ButtonStyle::Subtle)
        .disabled(local_actions_in_flight)
        .on_click(theme, cx, |this, e, window, cx| {
            let Some(repo_id) = this.active_repo_id() else {
                return;
            };
            let selection =
                this.status_section_action_selection(repo_id, StatusSection::CombinedUnstaged);
            if selection.paths.is_empty() {
                return;
            }
            this.open_popover_at(
                PopoverKind::DiscardChangesConfirm {
                    repo_id,
                    area: DiffArea::Unstaged,
                    path: selection.popover_path(),
                },
                e.position(),
                window,
                cx,
            );
            cx.notify();
        })
        .gitcomet_tooltip(
            theme,
            format!(
                "Discard changes in {selected_combined_unstaged} selected {}",
                status_action_file_count(selected_combined_unstaged)
            )
            .into(),
        );

        let untracked_paths_for_stage_all =
            gitcomet_state::msg::RepoPathList::from(untracked_paths.clone());
        let stage_all_untracked = components::Button::new(
            "stage_all_untracked",
            status_action_all_label(untracked_labels, "Stage all"),
        )
        .style(components::ButtonStyle::Subtle)
        .disabled(local_actions_in_flight || untracked_paths_for_stage_all.is_empty())
        .on_click(theme, cx, move |this, _e, _w, cx| {
            let Some(repo_id) = this.active_repo_id() else {
                return;
            };
            if untracked_paths_for_stage_all.is_empty() {
                return;
            }
            this.status_multi_selection.remove(&repo_id);
            this.store.dispatch(Msg::ClearDiffSelection { repo_id });
            this.store.dispatch(Msg::StagePaths {
                repo_id,
                paths: untracked_paths_for_stage_all.clone(),
            });
            cx.notify();
        })
        .gitcomet_tooltip(theme, "Stage all untracked files".into());

        let stage_selected_untracked = components::Button::new(
            "stage_selected_untracked",
            status_action_count_label(untracked_labels, "Stage", selected_untracked),
        )
        .style(components::ButtonStyle::Subtle)
        .disabled(local_actions_in_flight)
        .on_click(theme, cx, |this, _e, _w, cx| {
            let Some(repo_id) = this.active_repo_id() else {
                return;
            };
            // Read without consuming: the confirmation below can still be
            // cancelled, and that must leave the selection as the user built it.
            let selection = this.status_section_action_selection(repo_id, StatusSection::Untracked);
            let paths = selection.paths;
            if paths.is_empty() {
                return;
            }
            if let Some(confirm) = crate::view::conflict_markers::stage_confirm_popover(
                &this.state,
                repo_id,
                paths.clone(),
                selection.from_explicit_selection,
            ) {
                let anchor = crate::view::conflict_markers::centered_dialog_anchor(_w);
                this.open_popover_at(confirm, anchor, _w, cx);
                cx.notify();
                return;
            }
            if selection.from_explicit_selection {
                this.clear_status_multi_selection(repo_id);
            }
            this.store.dispatch(Msg::ClearDiffSelection { repo_id });
            this.store.dispatch(Msg::StagePaths {
                repo_id,
                paths: paths.into(),
            });
            cx.notify();
        })
        .gitcomet_tooltip(
            theme,
            format!(
                "Stage {selected_untracked} selected {}",
                status_action_file_count(selected_untracked)
            )
            .into(),
        );

        let discard_selected_untracked = components::Button::new(
            "discard_selected_untracked",
            status_action_count_label(untracked_labels, "Discard", selected_untracked),
        )
        .style(components::ButtonStyle::Subtle)
        .disabled(local_actions_in_flight)
        .on_click(theme, cx, |this, e, window, cx| {
            let Some(repo_id) = this.active_repo_id() else {
                return;
            };
            let selection = this.status_section_action_selection(repo_id, StatusSection::Untracked);
            if selection.paths.is_empty() {
                return;
            }
            this.open_popover_at(
                PopoverKind::DiscardChangesConfirm {
                    repo_id,
                    area: DiffArea::Unstaged,
                    path: selection.popover_path(),
                },
                e.position(),
                window,
                cx,
            );
            cx.notify();
        })
        .gitcomet_tooltip(
            theme,
            format!(
                "Discard changes in {selected_untracked} selected {}",
                status_action_file_count(selected_untracked)
            )
            .into(),
        );

        let split_unstaged_paths_for_stage_all = split_unstaged_paths.clone();
        let stage_all_split_unstaged = components::Button::new(
            "stage_all_split_unstaged",
            status_action_all_label(split_unstaged_labels, "Stage all"),
        )
        .style(components::ButtonStyle::Subtle)
        .disabled(local_actions_in_flight || split_unstaged_paths_for_stage_all.is_empty())
        .on_click(theme, cx, move |this, _e, _w, cx| {
            let Some(repo_id) = this.active_repo_id() else {
                return;
            };
            if split_unstaged_paths_for_stage_all.is_empty() {
                return;
            }
            // Named paths: this button stages the tracked-changes section only —
            // conflicted files among them, so it needs the same confirmation the
            // combined view's button gets.
            this.stage_all_with_conflict_confirmation(
                repo_id,
                split_unstaged_paths_for_stage_all.clone(),
                _w,
                cx,
            );
        })
        .gitcomet_tooltip(theme, "Stage all unstaged changes".into());

        let stage_selected_split_unstaged = components::Button::new(
            "stage_selected_split_unstaged",
            status_action_count_label(split_unstaged_labels, "Stage", selected_split_unstaged),
        )
        .style(components::ButtonStyle::Subtle)
        .disabled(local_actions_in_flight)
        .on_click(theme, cx, |this, _e, _w, cx| {
            let Some(repo_id) = this.active_repo_id() else {
                return;
            };
            // Read without consuming: the confirmation below can still be
            // cancelled, and that must leave the selection as the user built it.
            let selection = this.status_section_action_selection(repo_id, StatusSection::Unstaged);
            let paths = selection.paths;
            if paths.is_empty() {
                return;
            }
            if let Some(confirm) = crate::view::conflict_markers::stage_confirm_popover(
                &this.state,
                repo_id,
                paths.clone(),
                selection.from_explicit_selection,
            ) {
                let anchor = crate::view::conflict_markers::centered_dialog_anchor(_w);
                this.open_popover_at(confirm, anchor, _w, cx);
                cx.notify();
                return;
            }
            if selection.from_explicit_selection {
                this.clear_status_multi_selection(repo_id);
            }
            this.store.dispatch(Msg::ClearDiffSelection { repo_id });
            this.store.dispatch(Msg::StagePaths {
                repo_id,
                paths: paths.into(),
            });
            cx.notify();
        })
        .gitcomet_tooltip(
            theme,
            format!(
                "Stage {selected_split_unstaged} selected {}",
                status_action_file_count(selected_split_unstaged)
            )
            .into(),
        );

        let discard_selected_split_unstaged = components::Button::new(
            "discard_selected_split_unstaged",
            status_action_count_label(split_unstaged_labels, "Discard", selected_split_unstaged),
        )
        .style(components::ButtonStyle::Subtle)
        .disabled(local_actions_in_flight)
        .on_click(theme, cx, |this, e, window, cx| {
            let Some(repo_id) = this.active_repo_id() else {
                return;
            };
            let selection = this.status_section_action_selection(repo_id, StatusSection::Unstaged);
            if selection.paths.is_empty() {
                return;
            }
            this.open_popover_at(
                PopoverKind::DiscardChangesConfirm {
                    repo_id,
                    area: DiffArea::Unstaged,
                    path: selection.popover_path(),
                },
                e.position(),
                window,
                cx,
            );
            cx.notify();
        })
        .gitcomet_tooltip(
            theme,
            format!(
                "Discard changes in {selected_split_unstaged} selected {}",
                status_action_file_count(selected_split_unstaged)
            )
            .into(),
        );

        let unstage_all = components::Button::new(
            "unstage_all",
            status_action_all_label(staged_labels, "Unstage all changes"),
        )
        .style(components::ButtonStyle::Subtle)
        .disabled(local_actions_in_flight)
        .on_click(theme, cx, |this, _e, _w, cx| {
            let Some(repo_id) = this.active_repo_id() else {
                return;
            };
            this.status_multi_selection.remove(&repo_id);
            this.store.dispatch(Msg::ClearDiffSelection { repo_id });
            this.store.dispatch(Msg::UnstagePaths {
                repo_id,
                paths: Default::default(),
            });
            cx.notify();
        })
        .gitcomet_tooltip(theme, "Unstage all changes".into());

        let unstage_selected = components::Button::new(
            "unstage_selected",
            status_action_count_label(staged_labels, "Unstage", selected_staged),
        )
        .style(components::ButtonStyle::Subtle)
        .disabled(local_actions_in_flight)
        .on_click(theme, cx, |this, _e, _w, cx| {
            let Some(repo_id) = this.active_repo_id() else {
                return;
            };
            let paths = this
                .take_status_section_action_selection(repo_id, StatusSection::Staged)
                .paths;
            if paths.is_empty() {
                return;
            }
            this.store.dispatch(Msg::ClearDiffSelection { repo_id });
            this.store.dispatch(Msg::UnstagePaths {
                repo_id,
                paths: paths.into(),
            });
            cx.notify();
        })
        .gitcomet_tooltip(
            theme,
            format!(
                "Unstage {selected_staged} selected {}",
                status_action_file_count(selected_staged)
            )
            .into(),
        );

        let section_header = |id: &'static str,
                              title: gpui::AnyElement,
                              show_action: bool,
                              action: gpui::AnyElement|
         -> gpui::AnyElement {
            div()
                .id(id)
                .debug_selector(move || id.to_string())
                .flex()
                .items_center()
                .justify_between()
                .gap_2()
                .h(components::control_height_md(ui_scale_percent))
                .px_2()
                .overflow_hidden()
                // The labels shrink before this matters, but a UI zoom or a font
                // wider than the budget assumes can still overrun the header —
                // and then the title, not the actions, is what gives way.
                .child(
                    div()
                        .flex()
                        .flex_1()
                        .min_w(px(0.0))
                        .overflow_hidden()
                        .child(title),
                )
                .when(show_action, |d| d.child(div().flex_none().child(action)))
                .into_any_element()
        };

        let normal_header_title = |label: &'static str| {
            div()
                .text_sm()
                .font_weight(FontWeight::BOLD)
                .line_clamp(1)
                .whitespace_nowrap()
                .child(label)
                .into_any_element()
        };

        let section_min_h = px(STATUS_SECTION_MIN_HEIGHT_PX);
        let resize_handle_h = px(PANE_RESIZE_HANDLE_PX);

        let unstaged_actions = {
            let mut actions = div().flex().items_center().gap_2();
            if local_actions_in_flight {
                actions = actions.child(
                    spinner(
                        ("unstaged_actions_spinner", repo_key),
                        with_alpha(
                            theme.colors.accent.foreground,
                            if theme.is_dark { 0.72 } else { 0.82 },
                        ),
                    )
                    .into_any_element(),
                );
            }
            if selected_combined_unstaged > 0 {
                actions = actions.child(stage_selected).child(discard_selected);
            }
            actions.child(stage_all).into_any_element()
        };

        let untracked_actions = {
            let mut actions = div().flex().items_center().gap_2();
            if local_actions_in_flight {
                actions = actions.child(
                    spinner(
                        ("untracked_actions_spinner", repo_key),
                        with_alpha(
                            theme.colors.accent.foreground,
                            if theme.is_dark { 0.72 } else { 0.82 },
                        ),
                    )
                    .into_any_element(),
                );
            }
            if selected_untracked > 0 {
                actions = actions
                    .child(stage_selected_untracked)
                    .child(discard_selected_untracked);
            }
            actions.child(stage_all_untracked).into_any_element()
        };

        let split_unstaged_actions = {
            let mut actions = div().flex().items_center().gap_2();
            if local_actions_in_flight {
                actions = actions.child(
                    spinner(
                        ("split_unstaged_actions_spinner", repo_key),
                        with_alpha(
                            theme.colors.accent.foreground,
                            if theme.is_dark { 0.72 } else { 0.82 },
                        ),
                    )
                    .into_any_element(),
                );
            }
            if selected_split_unstaged > 0 {
                actions = actions
                    .child(stage_selected_split_unstaged)
                    .child(discard_selected_split_unstaged);
            }
            actions.child(stage_all_split_unstaged).into_any_element()
        };

        let staged_actions = {
            let mut actions = div().flex().items_center().gap_2();
            if local_actions_in_flight {
                actions = actions.child(
                    spinner(
                        ("staged_actions_spinner", repo_key),
                        with_alpha(
                            theme.colors.accent.foreground,
                            if theme.is_dark { 0.72 } else { 0.82 },
                        ),
                    )
                    .into_any_element(),
                );
            }
            if selected_staged > 0 {
                actions = actions.child(unstage_selected);
            }
            actions.child(unstage_all).into_any_element()
        };

        let unstaged_body = if unstaged_loading {
            components::empty_state_message(theme, "Loading…").into_any_element()
        } else if unstaged_count == 0 {
            components::empty_state_message(theme, "No unstaged changes.").into_any_element()
        } else {
            self.status_list(cx, StatusSection::CombinedUnstaged, unstaged_count)
        };

        let untracked_body = if untracked_loading {
            components::empty_state_message(theme, "Loading…").into_any_element()
        } else if untracked_count == 0 {
            components::empty_state_message(theme, "No untracked files.").into_any_element()
        } else {
            self.status_list(cx, StatusSection::Untracked, untracked_count)
        };

        let split_unstaged_body = if split_unstaged_loading {
            components::empty_state_message(theme, "Loading…").into_any_element()
        } else if split_unstaged_count == 0 {
            components::empty_state_message(theme, "No unstaged changes.").into_any_element()
        } else {
            self.status_list(cx, StatusSection::Unstaged, split_unstaged_count)
        };

        let staged_list = if staged_loading {
            components::empty_state_message(theme, "Loading…").into_any_element()
        } else if staged_count == 0 {
            components::empty_state_message(theme, "Nothing staged yet.").into_any_element()
        } else {
            self.status_list(cx, StatusSection::Staged, staged_count)
        };

        let build_change_tracking_header_title =
            |id: &'static str, invoker_key: &'static str, label: &'static str| {
                let change_tracking_invoker: SharedString = invoker_key.into();
                let change_tracking_active =
                    self.active_context_menu_invoker.as_ref() == Some(&change_tracking_invoker);
                let change_tracking_invoker = change_tracking_invoker.clone();
                div()
                    .id(id)
                    .flex()
                    .items_center()
                    .gap_1()
                    .px_1()
                    .h(px(18.0))
                    .rounded(px(theme.radii.row))
                    .when(change_tracking_active, |d| {
                        d.bg(theme.colors.interaction.pressed_background)
                    })
                    .hover(move |s| {
                        if change_tracking_active {
                            s.bg(theme.colors.interaction.pressed_background)
                        } else {
                            s.bg(with_alpha(theme.colors.interaction.hover_background, 0.55))
                        }
                    })
                    .active(move |s| s.bg(theme.colors.interaction.pressed_background))
                    .cursor(CursorStyle::PointingHand)
                    .child(
                        div()
                            .text_sm()
                            .font_weight(FontWeight::BOLD)
                            .line_clamp(1)
                            .whitespace_nowrap()
                            .child(label),
                    )
                    .child(svg_icon("icons/chevron_down.svg", icon_muted, px(12.0)))
                    .on_click(cx.listener(move |this, e: &ClickEvent, window, cx| {
                        this.activate_context_menu_invoker(change_tracking_invoker.clone(), cx);
                        this.open_popover_at(
                            PopoverKind::ChangeTrackingSettings,
                            e.position(),
                            window,
                            cx,
                        );
                        cx.notify();
                    }))
                    .into_any_element()
            };

        let build_unstaged_header_title = || {
            build_change_tracking_header_title(
                "change_tracking_unstaged_header",
                "change_tracking_unstaged_header",
                "Unstaged",
            )
        };

        let build_untracked_header_title = || {
            build_change_tracking_header_title(
                "change_tracking_untracked_header",
                "change_tracking_untracked_header",
                "Untracked",
            )
        };

        let active_status_resize = self.status_section_resize;
        let build_status_resize_handle = |id: &'static str, handle: StatusSectionResizeHandle| {
            let dragging = active_status_resize.is_some_and(|state| state.handle == handle);
            div()
                .id(id)
                .debug_selector(move || id.to_string())
                .group(id)
                .w_full()
                .h(resize_handle_h)
                .flex_none()
                .cursor(CursorStyle::ResizeUpDown)
                .child(components::resize_grip(
                    theme,
                    ui_scale,
                    id,
                    components::ResizeGripAxis::Horizontal,
                    dragging,
                    Some(theme.colors.stroke.default),
                ))
                .on_mouse_down(
                    MouseButton::Left,
                    cx.listener(move |this, e: &MouseDownEvent, window, cx| {
                        cx.stop_propagation();
                        crate::press_gesture::claim_press(cx);
                        this.start_status_section_resize(handle, e.position.y, cx);
                        window.refresh();
                    }),
                )
                .on_mouse_up(
                    MouseButton::Left,
                    cx.listener(move |this, _e, window, cx| {
                        if this
                            .status_section_resize
                            .is_some_and(|state| state.handle == handle)
                        {
                            this.finish_status_section_resize(cx);
                            window.refresh();
                        }
                    }),
                )
                .on_mouse_up_out(
                    MouseButton::Left,
                    cx.listener(move |this, _e, window, cx| {
                        if this
                            .status_section_resize
                            .is_some_and(|state| state.handle == handle)
                        {
                            this.finish_status_section_resize(cx);
                            window.refresh();
                        }
                    }),
                )
        };

        let with_split_sizing = |mut section: gpui::Div,
                                 exact_height: Option<Pixels>,
                                 fallback_grow: f32,
                                 min_h: Pixels| {
            section = section.min_h(min_h);
            if let Some(exact_height) = exact_height {
                let exact_height = exact_height.max(min_h);
                section = section.h(exact_height).max_h(exact_height);
                section.style().flex_grow = Some(0.0);
                section.style().flex_shrink = Some(0.0);
                section.style().flex_basis = Some(exact_height.into());
            } else {
                section.style().flex_grow = Some(fallback_grow.max(1.0));
                section.style().flex_shrink = Some(1.0);
                section.style().flex_basis = Some(relative(0.0).into());
            }
            section
        };
        let px_to_grow = |value: Pixels| -> f32 {
            let px_value: f32 = value.into();
            px_value.max(1.0)
        };

        let change_tracking_total_height =
            self.measured_status_sections_total_height(resize_handle_h);
        let change_tracking_heights = change_tracking_total_height.map(|total_height| {
            let top_height = resolved_vertical_split_height(
                self.change_tracking_height,
                total_height,
                min_change_tracking_stack_height(split_change_tracking, resize_handle_h),
                section_min_h,
            );
            (top_height, (total_height - top_height).max(section_min_h))
        });

        let untracked_total_height =
            self.resolved_measured_change_tracking_stack_total_height(resize_handle_h);
        let untracked_heights = untracked_total_height.map(|total_height| {
            let top_height = resolved_vertical_split_height(
                self.untracked_height,
                total_height,
                section_min_h,
                section_min_h,
            );
            (top_height, (total_height - top_height).max(section_min_h))
        });
        let unstaged_section = div()
            .flex()
            .flex_col()
            .min_h(section_min_h)
            .overflow_hidden()
            .child(section_header(
                "unstaged_header",
                build_unstaged_header_title(),
                unstaged_count > 0,
                unstaged_actions,
            ))
            .child(
                div()
                    .flex()
                    .flex_col()
                    .flex_1()
                    .min_h(px(0.0))
                    .overflow_hidden()
                    .child(unstaged_body),
            );

        let untracked_section = div()
            .flex()
            .flex_col()
            .min_h(section_min_h)
            .overflow_hidden()
            .child(section_header(
                "untracked_header",
                build_untracked_header_title(),
                untracked_count > 0,
                untracked_actions,
            ))
            .child(
                div()
                    .flex()
                    .flex_col()
                    .flex_1()
                    .min_h(px(0.0))
                    .overflow_hidden()
                    .child(untracked_body),
            );

        let split_unstaged_section = div()
            .flex()
            .flex_col()
            .min_h(section_min_h)
            .overflow_hidden()
            .child(section_header(
                "split_unstaged_header",
                build_unstaged_header_title(),
                split_unstaged_count > 0,
                split_unstaged_actions,
            ))
            .child(
                div()
                    .flex()
                    .flex_col()
                    .flex_1()
                    .min_h(px(0.0))
                    .overflow_hidden()
                    .child(split_unstaged_body),
            );

        let staged_section = div()
            .flex()
            .flex_col()
            .min_h(section_min_h)
            .overflow_hidden()
            .child(section_header(
                "staged_header",
                normal_header_title("Staged"),
                staged_count > 0,
                staged_actions,
            ))
            .child(
                div()
                    .flex()
                    .flex_col()
                    .flex_1()
                    .min_h(px(0.0))
                    .overflow_hidden()
                    .child(staged_list),
            );

        let change_tracking_section = if split_change_tracking {
            let change_tracking_stack_bounds_for_prepaint =
                std::rc::Rc::clone(&self.change_tracking_stack_bounds_ref);
            let stack_container = div()
                .relative()
                .flex()
                .flex_col()
                .w_full()
                .min_w_full()
                .max_w_full()
                .h_full()
                .min_h(min_change_tracking_stack_height(
                    split_change_tracking,
                    resize_handle_h,
                ))
                .overflow_hidden()
                .on_children_prepainted(move |children_bounds, window, _app| {
                    let next_bounds = children_bounds.first().copied();
                    let mut measured = change_tracking_stack_bounds_for_prepaint.borrow_mut();
                    if *measured != next_bounds {
                        *measured = next_bounds;
                        window.refresh();
                    }
                });
            let untracked_top_height = untracked_heights.map(|(top_height, _)| top_height);
            let split_unstaged_height = untracked_heights.map(|(_, bottom_height)| bottom_height);
            let (untracked_grow, split_unstaged_grow) = untracked_heights
                .map(|(top_height, bottom_height)| {
                    (px_to_grow(top_height), px_to_grow(bottom_height))
                })
                .unwrap_or((1.0, 1.0));
            stack_container
                .child(visible_bounds_probe())
                .child(
                    with_split_sizing(
                        untracked_section,
                        untracked_top_height,
                        untracked_grow,
                        section_min_h,
                    )
                    .debug_selector(|| "status_untracked_wrapper".to_string()),
                )
                .child(build_status_resize_handle(
                    "status_resize_untracked_unstaged",
                    StatusSectionResizeHandle::UntrackedAndUnstaged,
                ))
                .child(
                    with_split_sizing(
                        split_unstaged_section,
                        split_unstaged_height,
                        split_unstaged_grow,
                        section_min_h,
                    )
                    .debug_selector(|| "status_split_unstaged_wrapper".to_string()),
                )
        } else {
            unstaged_section
        };
        let (change_tracking_grow, staged_grow) = change_tracking_heights
            .map(|(top_height, bottom_height)| (px_to_grow(top_height), px_to_grow(bottom_height)))
            .unwrap_or((1.0, 1.0));
        let change_tracking_section = with_split_sizing(
            change_tracking_section,
            change_tracking_heights.map(|(top_height, _)| top_height),
            change_tracking_grow,
            min_change_tracking_stack_height(split_change_tracking, resize_handle_h),
        );
        let staged_section = with_split_sizing(
            staged_section,
            change_tracking_heights.map(|(_, bottom_height)| bottom_height),
            staged_grow,
            section_min_h,
        );
        let change_tracking_section =
            change_tracking_section.debug_selector(|| "status_change_tracking_wrapper".to_string());
        let staged_section = staged_section.debug_selector(|| "status_staged_wrapper".to_string());
        let status_sections_bounds_for_prepaint =
            std::rc::Rc::clone(&self.status_sections_bounds_ref);
        let status_sections_container = div()
            .relative()
            .w_full()
            .min_w_full()
            .max_w_full()
            .flex_1()
            .h_full()
            .min_h(px(0.0))
            .overflow_hidden()
            .on_children_prepainted(move |children_bounds, window, _app| {
                let next_bounds = children_bounds.first().copied();
                let mut measured = status_sections_bounds_for_prepaint.borrow_mut();
                if *measured != next_bounds {
                    *measured = next_bounds;
                    window.refresh();
                }
            });
        let status_sections = status_sections_container
            .child(visible_bounds_probe())
            .flex()
            .flex_col()
            .child(change_tracking_section)
            .child(build_status_resize_handle(
                "status_resize_change_tracking_staged",
                StatusSectionResizeHandle::ChangeTrackingAndStaged,
            ))
            .child(staged_section);

        div()
            .flex()
            .flex_col()
            .flex_1()
            .min_h(px(0.0))
            .h_full()
            .on_mouse_up(
                MouseButton::Left,
                cx.listener(|this, _e, _w, cx| {
                    this.finish_status_section_resize(cx);
                }),
            )
            .child(if repo_id.is_some() {
                div()
                    .flex()
                    .flex_col()
                    .flex_1()
                    .min_h(px(0.0))
                    .child(status_sections)
                    .child(div().px_2().py_2().child(self.commit_box(cx)))
                    .into_any_element()
            } else {
                components::empty_state(theme, "Changes", "No repository selected.")
                    .into_any_element()
            })
            .into_any_element()
    }

    pub(in super::super) fn status_list(
        &mut self,
        cx: &mut gpui::Context<Self>,
        section: StatusSection,
        count: usize,
    ) -> AnyElement {
        let theme = self.theme;
        if count == 0 {
            return components::empty_state_message(theme, "Working tree clean.")
                .into_any_element();
        }
        match section {
            StatusSection::CombinedUnstaged => {
                let list =
                    uniform_list("unstaged", count, cx.processor(Self::render_unstaged_rows))
                        .h_full()
                        .min_h(px(0.0))
                        .track_scroll(&self.unstaged_scroll);
                let list = restrict_scroll_to_vertical_axis(list);
                let list = div()
                    .flex_1()
                    .h_full()
                    .min_h(px(0.0))
                    .pr(components::Scrollbar::visible_gutter(
                        self.unstaged_scroll.clone(),
                        components::ScrollbarAxis::Vertical,
                    ))
                    .child(list);
                div()
                    .id("unstaged_scroll_container")
                    .relative()
                    .flex()
                    .flex_col()
                    .flex_1()
                    .h_full()
                    .min_h(px(0.0))
                    .overflow_hidden()
                    .child(list)
                    .child(
                        components::Scrollbar::new(
                            "unstaged_scrollbar",
                            self.unstaged_scroll.clone(),
                        )
                        .render(theme),
                    )
                    .into_any_element()
            }
            StatusSection::Untracked => {
                let list = uniform_list(
                    "untracked",
                    count,
                    cx.processor(Self::render_untracked_rows),
                )
                .h_full()
                .min_h(px(0.0))
                .track_scroll(&self.untracked_scroll);
                let list = restrict_scroll_to_vertical_axis(list);
                let list = div()
                    .flex_1()
                    .h_full()
                    .min_h(px(0.0))
                    .pr(components::Scrollbar::visible_gutter(
                        self.untracked_scroll.clone(),
                        components::ScrollbarAxis::Vertical,
                    ))
                    .child(list);
                div()
                    .id("untracked_scroll_container")
                    .relative()
                    .flex()
                    .flex_col()
                    .flex_1()
                    .h_full()
                    .min_h(px(0.0))
                    .overflow_hidden()
                    .child(list)
                    .child(
                        components::Scrollbar::new(
                            "untracked_scrollbar",
                            self.untracked_scroll.clone(),
                        )
                        .render(theme),
                    )
                    .into_any_element()
            }
            StatusSection::Unstaged => {
                let list = uniform_list(
                    "split_unstaged",
                    count,
                    cx.processor(Self::render_split_unstaged_rows),
                )
                .h_full()
                .min_h(px(0.0))
                .track_scroll(&self.unstaged_scroll);
                let list = restrict_scroll_to_vertical_axis(list);
                let list = div()
                    .flex_1()
                    .h_full()
                    .min_h(px(0.0))
                    .pr(components::Scrollbar::visible_gutter(
                        self.unstaged_scroll.clone(),
                        components::ScrollbarAxis::Vertical,
                    ))
                    .child(list);
                div()
                    .id("split_unstaged_scroll_container")
                    .relative()
                    .flex()
                    .flex_col()
                    .flex_1()
                    .h_full()
                    .min_h(px(0.0))
                    .overflow_hidden()
                    .child(list)
                    .child(
                        components::Scrollbar::new(
                            "split_unstaged_scrollbar",
                            self.unstaged_scroll.clone(),
                        )
                        .render(theme),
                    )
                    .into_any_element()
            }
            StatusSection::Staged => {
                let list = uniform_list("staged", count, cx.processor(Self::render_staged_rows))
                    .h_full()
                    .min_h(px(0.0))
                    .track_scroll(&self.staged_scroll);
                let list = restrict_scroll_to_vertical_axis(list);
                let list = div()
                    .flex_1()
                    .h_full()
                    .min_h(px(0.0))
                    .pr(components::Scrollbar::visible_gutter(
                        self.staged_scroll.clone(),
                        components::ScrollbarAxis::Vertical,
                    ))
                    .child(list);
                div()
                    .id("staged_scroll_container")
                    .relative()
                    .flex()
                    .flex_col()
                    .flex_1()
                    .h_full()
                    .min_h(px(0.0))
                    .overflow_hidden()
                    .child(list)
                    .child(
                        components::Scrollbar::new("staged_scrollbar", self.staged_scroll.clone())
                            .render(theme),
                    )
                    .into_any_element()
            }
        }
    }

    pub(in super::super) fn commit_box(&mut self, cx: &mut gpui::Context<Self>) -> gpui::Div {
        let theme = self.theme;
        let ui_scale_percent = crate::ui_scale::current(cx).percent;
        let commit_in_flight = self
            .active_repo()
            .is_some_and(|repo| repo.commit_in_flight > 0);
        let commit_message_text = self.commit_message_input.read(cx).text().to_string();
        let can_submit_commit = Self::can_submit_commit(
            self.active_repo(),
            &commit_message_text,
            self.commit_amend_enabled,
        );
        let repo_key = self.active_repo_id().map(|id| id.0).unwrap_or(0);
        let icon_color = theme.colors.accent.foreground;
        let icon = |path: &'static str| svg_icon(path, icon_color, px(14.0));
        let spinner = |id: (&'static str, u64)| svg_spinner(id, icon_color, px(14.0));
        let commit_label = match (self.commit_amend_enabled, self.commit_push_after_enabled) {
            (false, false) => "Commit",
            (false, true) => "Commit changes and Push",
            (true, false) => "Amend Previous Commit",
            (true, true) => "Amend and Push Safely",
        };
        let commit_tooltip = match (self.commit_amend_enabled, self.commit_push_after_enabled) {
            (false, false) => "Commit staged changes",
            (false, true) => "Commit staged changes and push",
            (true, false) => "Amend the previous commit",
            (true, true) => {
                "Amend the previous commit; published amends require explicit force push with lease"
            }
        };
        let commit_options_invoker: SharedString = "commit_options".into();
        let commit_options_active = self
            .active_context_menu_invoker
            .as_ref()
            .is_some_and(|id| id.as_ref() == commit_options_invoker.as_ref());
        let previous_messages_invoker: SharedString = "previous_commit_messages".into();
        let previous_messages_active = self
            .active_context_menu_invoker
            .as_ref()
            .is_some_and(|id| id.as_ref() == previous_messages_invoker.as_ref());
        let menu_selected_bg = with_alpha(
            theme.colors.accent.foreground,
            if theme.is_dark { 0.26 } else { 0.20 },
        );
        let menu_icon_color = if commit_options_active {
            theme.colors.accent.foreground
        } else {
            theme.colors.foreground.secondary
        };
        let previous_messages_icon_color = if previous_messages_active {
            theme.colors.accent.foreground
        } else {
            theme.colors.foreground.secondary
        };
        let commit_message = components::ScrollContainer::vertical(
            ("commit_message_scroll_surface", repo_key),
            ("commit_message_scrollbar", repo_key),
            self.commit_message_scroll.clone(),
            px(COMMIT_MESSAGE_INPUT_MAX_HEIGHT_PX),
        )
        .container_id(("commit_message_container", repo_key))
        .render(theme, self.commit_message_input.clone());
        let commit_main = components::Button::new("commit", commit_label)
            .rounded_left()
            .start_slot(if commit_in_flight {
                spinner(("commit_spinner", repo_key)).into_any_element()
            } else {
                icon("icons/check.svg").into_any_element()
            })
            .style(components::ButtonStyle::Subtle)
            .disabled(!can_submit_commit)
            .on_click(theme, cx, |this, _e, _w, cx| {
                let _ = this.submit_commit(cx);
            })
            .debug_selector(|| "commit_button".to_string())
            .gitcomet_tooltip(theme, commit_tooltip.into());
        let commit_menu = components::Button::new("commit_options", "")
            .rounded_right()
            .start_slot(svg_icon(
                "icons/chevron_down.svg",
                menu_icon_color,
                px(14.0),
            ))
            .style(components::ButtonStyle::Subtle)
            .selected(commit_options_active)
            .selected_bg(menu_selected_bg)
            .disabled(self.active_repo_id().is_none())
            .on_click(theme, cx, move |this, e, window, cx| {
                let Some(repo_id) = this.active_repo_id() else {
                    return;
                };
                this.activate_context_menu_invoker(commit_options_invoker.clone(), cx);
                this.open_popover_at(
                    PopoverKind::CommitOptionsMenu { repo_id },
                    e.position(),
                    window,
                    cx,
                );
            })
            .gitcomet_tooltip(theme, "Commit options".into());
        let previous_messages_menu = components::Button::new("previous_commit_messages", "")
            .start_slot(svg_icon(
                "icons/history.svg",
                previous_messages_icon_color,
                px(14.0),
            ))
            .style(components::ButtonStyle::Subtle)
            .selected(previous_messages_active)
            .selected_bg(menu_selected_bg)
            .disabled(self.active_repo_id().is_none())
            .on_click(theme, cx, move |this, e, window, cx| {
                let Some(repo_id) = this.active_repo_id() else {
                    return;
                };
                this.activate_context_menu_invoker(previous_messages_invoker.clone(), cx);
                this.open_popover_at(
                    PopoverKind::PreviousCommitMessagesMenu { repo_id },
                    e.position(),
                    window,
                    cx,
                );
            })
            .debug_selector(|| "previous_commit_messages_button".to_string())
            .gitcomet_tooltip(theme, "Previous commit messages".into());
        div().flex().flex_col().gap_2().child(commit_message).child(
            div().flex().items_center().justify_end().child(
                div()
                    .flex()
                    .items_center()
                    .gap_2()
                    .child(previous_messages_menu)
                    .child(
                        components::SplitButton::new(commit_main, commit_menu)
                            .style(components::SplitButtonStyle::Filled)
                            .render(theme, ui_scale_percent)
                            .debug_selector(|| "commit_split_button".to_string()),
                    ),
            ),
        )
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use gitcomet_core::domain::{Branch, CommitId, LogPage, RepoSpec};
    use gitcomet_state::model::{Loadable, RepoId, RepoState};
    use std::path::PathBuf;
    use std::sync::Arc;

    fn test_repo() -> RepoState {
        RepoState::new_opening(
            RepoId(1),
            RepoSpec {
                workdir: PathBuf::from("/tmp/repo"),
            },
        )
    }

    /// Label lengths the unstaged header asks about when three files are picked:
    /// `Stage (3)`, `Discard (3)`, `Stage all changes`.
    fn unstaged_header_with_selection() -> [usize; 3] {
        [
            "Stage (3)".len(),
            "Discard (3)".len(),
            "Stage all changes".len(),
        ]
    }

    #[test]
    fn status_action_labels_stay_full_in_a_wide_panel() {
        assert_eq!(
            status_action_labels_for_width(
                px(600.0),
                "Unstaged".len(),
                true,
                &unstaged_header_with_selection(),
                false,
                crate::ui_scale::DEFAULT_UI_SCALE_PERCENT,
            ),
            StatusActionLabels::Full
        );
    }

    /// Guards the calibration in one direction only: a budget that runs long
    /// withholds the full wording while there is visibly room for it, which is
    /// the failure this pins. The number comes from measuring the shipped font
    /// — `Stage (3)`, `Discard (3)` and `Stage all changes` plus their padding,
    /// gaps and the `Unstaged` dropdown title need ~424px of real ink and box.
    #[test]
    fn status_action_labels_expand_as_soon_as_the_row_really_fits() {
        assert_eq!(
            status_action_labels_for_width(
                px(430.0),
                "Unstaged".len(),
                true,
                &unstaged_header_with_selection(),
                false,
                crate::ui_scale::DEFAULT_UI_SCALE_PERCENT,
            ),
            StatusActionLabels::Full,
            "the full labels fit at this width in the real app, so the header must show them"
        );
    }

    #[test]
    fn status_action_labels_shrink_once_the_panel_is_narrow() {
        assert_eq!(
            status_action_labels_for_width(
                px(200.0),
                "Unstaged".len(),
                true,
                &unstaged_header_with_selection(),
                false,
                crate::ui_scale::DEFAULT_UI_SCALE_PERCENT,
            ),
            StatusActionLabels::Compact
        );
    }

    #[test]
    fn status_action_labels_survive_narrower_without_a_selection() {
        // With nothing selected the header carries one button, so the width that
        // forces the three-button header to shrink is still comfortable here.
        let width = px(260.0);
        assert_eq!(
            status_action_labels_for_width(
                width,
                "Unstaged".len(),
                true,
                &unstaged_header_with_selection(),
                false,
                crate::ui_scale::DEFAULT_UI_SCALE_PERCENT,
            ),
            StatusActionLabels::Compact
        );
        assert_eq!(
            status_action_labels_for_width(
                width,
                "Unstaged".len(),
                true,
                &["Stage all changes".len()],
                false,
                crate::ui_scale::DEFAULT_UI_SCALE_PERCENT,
            ),
            StatusActionLabels::Full
        );
    }

    #[test]
    fn status_action_labels_account_for_the_in_flight_spinner() {
        // Sized to fit the buttons and title exactly, so the spinner is the only
        // thing that can push it over.
        let mut width = px(0.0);
        for candidate in (200..=600).step_by(2) {
            width = px(candidate as f32);
            if status_action_labels_for_width(
                width,
                "Unstaged".len(),
                true,
                &unstaged_header_with_selection(),
                false,
                crate::ui_scale::DEFAULT_UI_SCALE_PERCENT,
            ) == StatusActionLabels::Full
            {
                break;
            }
        }
        assert_eq!(
            status_action_labels_for_width(
                width,
                "Unstaged".len(),
                true,
                &unstaged_header_with_selection(),
                true,
                crate::ui_scale::DEFAULT_UI_SCALE_PERCENT,
            ),
            StatusActionLabels::Compact,
            "the spinner's own width has to count against the budget"
        );
    }

    #[test]
    fn status_action_labels_shrink_earlier_when_zoomed_in() {
        let width = px(500.0);
        assert_eq!(
            status_action_labels_for_width(
                width,
                "Unstaged".len(),
                true,
                &unstaged_header_with_selection(),
                false,
                crate::ui_scale::DEFAULT_UI_SCALE_PERCENT,
            ),
            StatusActionLabels::Full
        );
        assert_eq!(
            status_action_labels_for_width(
                width,
                "Unstaged".len(),
                true,
                &unstaged_header_with_selection(),
                false,
                200,
            ),
            StatusActionLabels::Compact
        );
    }

    #[test]
    fn status_action_labels_default_to_full_before_the_panel_is_measured() {
        assert_eq!(
            status_action_labels_for_width(
                px(0.0),
                "Unstaged".len(),
                true,
                &unstaged_header_with_selection(),
                false,
                crate::ui_scale::DEFAULT_UI_SCALE_PERCENT,
            ),
            StatusActionLabels::Full
        );
    }

    #[test]
    fn status_action_labels_rewrite_the_wording() {
        assert_eq!(
            status_action_count_label(StatusActionLabels::Full, "Discard", 12),
            "Discard (12)"
        );
        assert_eq!(
            status_action_count_label(StatusActionLabels::Compact, "Stage", 3),
            "Stg (3)"
        );
        assert_eq!(
            status_action_count_label(StatusActionLabels::Compact, "Discard", 12),
            "Disc (12)"
        );
        assert_eq!(
            status_action_count_label(StatusActionLabels::Compact, "Unstage", 1),
            "Ustg (1)"
        );
        assert_eq!(
            status_action_all_label(StatusActionLabels::Full, "Unstage all changes"),
            "Unstage all changes"
        );
        assert_eq!(
            status_action_all_label(StatusActionLabels::Compact, "Unstage all changes"),
            "All"
        );
    }

    fn file_status(path: &str, kind: FileStatusKind) -> FileStatus {
        FileStatus {
            path: PathBuf::from(path),
            kind,
            conflict: None,
        }
    }

    fn repo_with_status(status: RepoStatus) -> RepoState {
        let mut repo = test_repo();
        repo.worktree_status = Loadable::Ready(Arc::new(status.unstaged.clone()));
        repo.worktree_status_rev = 1;
        repo.staged_status = Loadable::Ready(Arc::new(status.staged.clone()));
        repo.staged_status_rev = 1;
        repo.status = Loadable::Ready(status.into());
        repo.status_rev = 1;
        repo
    }

    fn branch(name: &str, target: &str) -> Branch {
        Branch {
            name: name.to_string(),
            target: CommitId(target.into()),
            upstream: None,
            divergence: None,
        }
    }

    #[test]
    fn commit_allowed_when_staged_changes_exist() {
        assert!(commit_allowed(false, 1));
    }

    #[test]
    fn commit_allowed_when_merge_is_active_without_staged_changes() {
        let mut repo = test_repo();
        repo.merge_commit_message = Loadable::Ready(Some("Merge branch 'feature'".to_string()));
        assert!(commit_allowed(merge_active(Some(&repo)), 0));
    }

    #[test]
    fn commit_not_allowed_without_staged_changes_or_merge() {
        assert!(!commit_allowed(false, 0));
    }

    #[test]
    fn amend_allowed_when_filtered_log_is_empty_but_head_branch_exists() {
        let mut repo = test_repo();
        repo.head_branch = Loadable::Ready("main".to_string());
        repo.branches = Loadable::Ready(Arc::new(vec![branch("main", "abc123")]));
        repo.log = Loadable::Ready(Arc::new(LogPage {
            commits: Vec::new(),
            next_cursor: None,
        }));

        assert!(DetailsPaneView::can_submit_commit(
            Some(&repo),
            "message",
            true
        ));
    }

    #[test]
    fn amend_not_allowed_on_unborn_head_branch() {
        let mut repo = test_repo();
        repo.head_branch = Loadable::Ready("main".to_string());
        repo.branches = Loadable::Ready(Arc::new(Vec::new()));
        repo.log = Loadable::Ready(Arc::new(LogPage {
            commits: Vec::new(),
            next_cursor: None,
        }));

        assert!(!DetailsPaneView::can_submit_commit(
            Some(&repo),
            "message",
            true
        ));
    }

    #[test]
    fn amend_allowed_on_detached_head_without_visible_log_entry() {
        let mut repo = test_repo();
        repo.head_branch = Loadable::Ready("HEAD".to_string());
        repo.log = Loadable::Ready(Arc::new(LogPage {
            commits: Vec::new(),
            next_cursor: None,
        }));

        assert!(DetailsPaneView::can_submit_commit(
            Some(&repo),
            "message",
            true
        ));
    }

    #[test]
    fn split_height_clamps_to_minimum_section_heights() {
        let min_h = px(STATUS_SECTION_MIN_HEIGHT_PX);
        let total_h = px(400.0);

        let top_clamped = clamp_vertical_split_height(px(-300.0), total_h, min_h, min_h);
        let bottom_clamped = clamp_vertical_split_height(px(900.0), total_h, min_h, min_h);

        assert_eq!(top_clamped, min_h);
        assert_eq!(bottom_clamped, total_h - min_h);
    }

    #[test]
    fn resolved_split_height_defaults_to_half_when_unset() {
        let min_h = px(STATUS_SECTION_MIN_HEIGHT_PX);
        let total_h = px(400.0);

        assert_eq!(
            resolved_vertical_split_height(None, total_h, min_h, min_h),
            px(200.0)
        );
    }

    #[test]
    fn split_change_tracking_min_height_includes_inner_handle() {
        assert_eq!(
            min_change_tracking_stack_height(false, px(PANE_RESIZE_HANDLE_PX)),
            px(STATUS_SECTION_MIN_HEIGHT_PX)
        );
        assert_eq!(
            min_change_tracking_stack_height(true, px(PANE_RESIZE_HANDLE_PX)),
            px((STATUS_SECTION_MIN_HEIGHT_PX * 2.0) + PANE_RESIZE_HANDLE_PX)
        );
    }

    #[test]
    fn restored_status_section_heights_clamp_to_visible_minimums() {
        assert_eq!(
            DetailsPaneView::sanitized_restored_change_tracking_height(
                ChangeTrackingView::Combined,
                Some(1),
            ),
            Some(px(STATUS_SECTION_MIN_HEIGHT_PX))
        );
        assert_eq!(
            DetailsPaneView::sanitized_restored_change_tracking_height(
                ChangeTrackingView::SplitUntracked,
                Some(1),
            ),
            Some(px(
                (STATUS_SECTION_MIN_HEIGHT_PX * 2.0) + PANE_RESIZE_HANDLE_PX
            ))
        );
        assert_eq!(
            DetailsPaneView::sanitized_restored_untracked_height(Some(1)),
            Some(px(STATUS_SECTION_MIN_HEIGHT_PX))
        );
    }

    #[test]
    fn status_section_action_selection_falls_back_to_active_combined_unstaged_row() {
        let repo = repo_with_status(RepoStatus {
            unstaged: vec![file_status("src/lib.rs", FileStatusKind::Modified)],
            staged: Vec::new(),
        });
        let diff_target = DiffTarget::WorkingTree {
            path: PathBuf::from("src/lib.rs"),
            area: DiffArea::Unstaged,
        };

        let selection = status_section_action_selection(
            &repo,
            Some(&diff_target),
            None,
            StatusSection::CombinedUnstaged,
        );

        assert_eq!(
            selection,
            StatusSectionActionSelection {
                paths: vec![PathBuf::from("src/lib.rs")],
                from_explicit_selection: false,
            }
        );
        assert_eq!(selection.popover_path(), Some(PathBuf::from("src/lib.rs")));
    }

    #[test]
    fn status_section_action_selection_limits_active_row_to_matching_split_section() {
        let repo = repo_with_status(RepoStatus {
            unstaged: vec![
                file_status("new.txt", FileStatusKind::Untracked),
                file_status("src/lib.rs", FileStatusKind::Modified),
            ],
            staged: Vec::new(),
        });
        let diff_target = DiffTarget::WorkingTree {
            path: PathBuf::from("new.txt"),
            area: DiffArea::Unstaged,
        };

        let untracked = status_section_action_selection(
            &repo,
            Some(&diff_target),
            None,
            StatusSection::Untracked,
        );
        let unstaged = status_section_action_selection(
            &repo,
            Some(&diff_target),
            None,
            StatusSection::Unstaged,
        );

        assert_eq!(
            untracked,
            StatusSectionActionSelection {
                paths: vec![PathBuf::from("new.txt")],
                from_explicit_selection: false,
            }
        );
        assert!(unstaged.paths.is_empty());
    }

    #[test]
    fn status_section_action_selection_prefers_explicit_selection_over_active_row() {
        let selected_a = PathBuf::from("src/lib.rs");
        let selected_b = PathBuf::from("src/main.rs");
        let repo = repo_with_status(RepoStatus {
            unstaged: vec![
                file_status(
                    selected_a.to_string_lossy().as_ref(),
                    FileStatusKind::Modified,
                ),
                file_status(
                    selected_b.to_string_lossy().as_ref(),
                    FileStatusKind::Modified,
                ),
            ],
            staged: Vec::new(),
        });
        let diff_target = DiffTarget::WorkingTree {
            path: PathBuf::from("src/other.rs"),
            area: DiffArea::Unstaged,
        };
        let selection = StatusMultiSelection {
            unstaged: vec![selected_a.clone(), selected_b.clone()],
            ..Default::default()
        };

        let action_selection = status_section_action_selection(
            &repo,
            Some(&diff_target),
            Some(&selection),
            StatusSection::CombinedUnstaged,
        );

        assert_eq!(
            action_selection,
            StatusSectionActionSelection {
                paths: vec![selected_a, selected_b],
                from_explicit_selection: true,
            }
        );
        assert_eq!(action_selection.popover_path(), None);
    }
}
