use super::*; use crate::app::git::{GitChangeKind, GitDiffLine, GitDiffLineKind, GitDiffRow}; use crate::app::{git_diff_new_scroll_id, git_diff_old_scroll_id}; use crate::config::GitDiffLayout; use iced::widget::column; impl App { pub(super) fn git_detail(&self) -> Element<'_, Message> { let Some(project) = self.selected_project() else { return git_empty( "No project selected", "Choose a project to inspect its worktree.", ); }; let Some(state) = self.git_states.get(&project.project.id) else { return git_empty( "Not a Git worktree", "The selected project is not inside a Git repository.", ); }; let Some(worktree) = self.git_worktrees.get(&project.project.id) else { return git_empty("Reading worktree", "Git status will appear here shortly."); }; let counts = [ (GitChangeKind::Added, "Added"), (GitChangeKind::Modified, "Changed"), (GitChangeKind::Deleted, "Deleted"), ]; let mut file_list = column![].spacing(0); for (kind, label) in counts { let files = worktree .files .iter() .filter(|file| file.kind() == kind) .collect::>(); if files.is_empty() { continue; } file_list = file_list.push( container( row![ text(label).size(11).color(muted_text()), Space::new().width(Length::Fill), text(files.len()).size(11).color(muted_text()), ] .align_y(Alignment::Center), ) .padding([8, 12]) .width(Length::Fill), ); for file in files { let path = file.path.clone(); let selected = self.git_selected_project == Some(project.project.id) && self.git_selected_files.contains(&file.path); let staged = file.staged_kind.map(|kind| format!("S {}", kind.marker())); let worktree = file .worktree_kind .map(|kind| format!("W {}", kind.marker())); file_list = file_list.push(rule::horizontal(1)).push( container( row![ toggle(selected).on_toggle({ let path = path.clone(); move |_| Message::ToggleGitFile(path.clone()) }), text(kind.marker()).size(12).color(git_change_color(kind)), button(text(&file.display_path).size(13)) .padding(0) .style(button::text) .on_press(Message::OpenGitDiff(path)), Space::new().width(Length::Fill), staged.map(|label| text(label).size(11).color(staged_color())), worktree.map(|label| text(label).size(11).color(muted_text())), ] .spacing(8) .align_y(Alignment::Center), ) .padding([9, 12]) .width(Length::Fill), ); } } if worktree.files.is_empty() { file_list = file_list.push( container( column![ text("Worktree clean").size(18), text("There are no staged or unstaged changes.") .size(13) .color(muted_text()), ] .spacing(6) .align_x(Alignment::Center), ) .padding(36) .center_x(Length::Fill) .width(Length::Fill), ); } let selected = self.git_selected_project == Some(project.project.id); let can_stage = selected && worktree.files.iter().any(|file| { self.git_selected_files.contains(&file.path) && file.worktree_kind.is_some() }); let can_unstage = selected && worktree.files.iter().any(|file| { self.git_selected_files.contains(&file.path) && file.staged_kind.is_some() }); let idle = self.git_operation.is_none(); let commit_ready = idle && !worktree.files.is_empty() && !self.git_commit_message.trim().is_empty(); let commit_input: Element<'_, Message> = if self.modal_open() { container( text(if self.git_commit_message.is_empty() { "Commit message" } else { &self.git_commit_message }) .size(13) .color(muted_text()), ) .padding(9) .width(Length::Fill) .style(preference_group_style) .into() } else { text_input("Commit message", &self.git_commit_message) .on_input(Message::GitCommitMessageChanged) .on_submit(Message::GitCommit) .padding(9) .size(13) .into() }; let controls = column![ row![ git_icon_action( "+", "Stage selected files", (idle && can_stage).then_some(Message::GitStageSelected) ), git_icon_action( "−", "Unstage selected files", (idle && can_unstage).then_some(Message::GitUnstageSelected) ), commit_input, git_icon_action("●", "Commit", commit_ready.then_some(Message::GitCommit)), Space::new().width(6), git_icon_action("↻", "Fetch from origin", idle.then_some(Message::GitFetch)), git_icon_action("↓", "Pull from origin", idle.then_some(Message::GitPull)), git_icon_action("↑", "Push to origin", idle.then_some(Message::GitPush)), ] .spacing(7) .align_y(Alignment::Center), self.git_operation .as_ref() .map(|operation| text(format!("{}…", operation.label)) .size(12) .color(muted_text())), ] .spacing(6); let summary = format!( "{} change{}", worktree.files.len(), if worktree.files.len() == 1 { "" } else { "s" } ); container( column![ row![ column![ text(&project.project.name).size(20), text(worktree.root.display().to_string()) .size(11) .color(muted_text()), ] .spacing(4), Space::new().width(Length::Fill), column![ text(&state.label).size(13), text(summary).size(11).color(muted_text()), ] .spacing(4) .align_x(Alignment::End), ] .align_y(Alignment::Center), container(scrollable(file_list).height(Length::Fill)) .height(Length::Fill) .width(Length::Fill) .style(overview_style), container(controls) .padding(12) .width(Length::Fill) .style(overview_style), ] .spacing(12), ) .padding(Padding::new(28.0).top(22.0)) .height(Length::Fill) .width(Length::Fill) .into() } pub(super) fn git_commit_all_panel(&self) -> Element<'_, Message> { let dialog = container( column![ text("Commit all changes?").size(24), text("No files are selected and no changes are staged. All worktree changes will be staged and committed.") .size(14), row![ Space::new().width(Length::Fill), action_button("Cancel").on_press(Message::DismissPanel), action_button("Commit all changes").on_press(Message::ConfirmGitCommitAll), ] .spacing(8), ] .spacing(12), ) .padding(22) .width(500) .style(overview_style); opaque( container(dialog) .center_x(Length::Fill) .center_y(Length::Fill) .style(modal_backdrop_style), ) } pub(super) fn git_diff_panel(&self) -> Element<'_, Message> { let diff = self.git_diff.as_ref().unwrap(); let unified_active = self.git_diff_layout == GitDiffLayout::Unified; let split_active = self.git_diff_layout == GitDiffLayout::Split; let mode = container( row![ button(text("1 column").size(12)) .padding([4, 10]) .on_press(Message::SetGitDiffLayout(GitDiffLayout::Unified)) .style(move |theme, status| segmented_button_style( theme, status, unified_active )), button(text("2 columns").size(12)) .padding([4, 10]) .on_press(Message::SetGitDiffLayout(GitDiffLayout::Split)) .style(move |theme, status| segmented_button_style( theme, status, split_active )), ] .spacing(2), ) .padding(2) .style(segmented_control_style); let content = if unified_active { unified_diff(&diff.lines) } else { split_diff(diff.split_rows()) }; let diff_scroll = scrollable(content).height(Length::Fill).width(Length::Fill); let dialog = container( column![ row![ column![text("File diff").size(22), text(&diff.path).size(13)].spacing(4), Space::new().width(Length::Fill), mode, action_button("Close").on_press(Message::DismissPanel), ] .spacing(10) .align_y(Alignment::Center), container(diff_scroll) .height(Length::Fill) .width(Length::Fill) .style(overview_style), ] .spacing(12), ) .padding(18) .max_width(1_240) .max_height(840) .width(Length::Fill) .height(Length::Fill) .style(overview_style); opaque( container(dialog) .padding([42, 32]) .center_x(Length::Fill) .center_y(Length::Fill) .style(modal_backdrop_style), ) } } fn git_empty<'a>(title: &'a str, description: &'a str) -> Element<'a, Message> { container( column![ text(title).size(24), text(description).size(14).color(muted_text()), ] .spacing(8) .align_x(Alignment::Center), ) .center_x(Length::Fill) .center_y(Length::Fill) .into() } fn git_icon_action<'a>( symbol: &'a str, label: &'a str, message: Option, ) -> Element<'a, Message> { let mut action = action_button(text(symbol).size(17)).padding([5, 9]); if let Some(message) = message { action = action.on_press(message); } tooltip( action, container(text(label).size(12)) .padding(8) .style(preference_group_style), tooltip::Position::Top, ) .gap(5) .into() } fn unified_diff(lines: &[GitDiffLine]) -> Element<'_, Message> { let mut content = column![].spacing(0).width(Length::Shrink); for line in lines { content = content.push(diff_line(line, false)); } content.into() } fn split_diff(rows: Vec) -> Element<'static, Message> { let width = split_diff_width(&rows); let old = scrollable(split_diff_half(&rows, true, width)) .id(git_diff_old_scroll_id()) .direction(scrollable::Direction::Horizontal( scrollable::Scrollbar::new(), )) .on_scroll(Message::GitDiffScrolled) .width(Length::Fill); let new = scrollable(split_diff_half(&rows, false, width)) .id(git_diff_new_scroll_id()) .direction(scrollable::Direction::Horizontal( scrollable::Scrollbar::new(), )) .on_scroll(Message::GitDiffScrolled) .width(Length::Fill); row![old, rule::vertical(1), new].into() } fn split_diff_width(rows: &[GitDiffRow]) -> f32 { rows.iter() .flat_map(|row| match row { GitDiffRow::Header(line) => [Some(line), None], GitDiffRow::Pair { old, new } => [old.as_ref(), new.as_ref()], }) .flatten() .map(|line| { line.text .chars() .map(|character| if character == '\t' { 4 } else { 1 }) .sum::() }) .max() .unwrap_or_default() as f32 * 8.0 + 66.0 } fn split_diff_half(rows: &[GitDiffRow], old: bool, width: f32) -> Element<'static, Message> { let mut content = column![].spacing(0).width(width); for row in rows { content = content.push(match row { GitDiffRow::Header(line) => diff_line(line, old), GitDiffRow::Pair { old: old_line, new: new_line, } => diff_half( if old { old_line.as_ref() } else { new_line.as_ref() }, old, ), }); } content.into() } fn diff_half(line: Option<&GitDiffLine>, old: bool) -> Element<'static, Message> { line.map_or_else( || { container(Space::new().width(Length::Fill)) .height(24) .width(Length::Fill) .into() }, |line| diff_line(line, old), ) } fn diff_line(line: &GitDiffLine, old: bool) -> Element<'static, Message> { let kind = line.kind; let number = if old { line.old_number } else { line.new_number.or(line.old_number) }; container( row![ text(number.map_or_else(String::new, |number| number.to_string())) .font(iced::Font::MONOSPACE) .size(11) .color(muted_text()) .width(42), text(line.text.clone()) .font(iced::Font::MONOSPACE) .size(12) .wrapping(iced::widget::text::Wrapping::None), ] .spacing(8) .align_y(Alignment::Center), ) .padding([3, 8]) .height(24) .width(Length::Fill) .style(move |_| diff_line_style(kind)) .into() } fn diff_line_style(kind: GitDiffLineKind) -> container::Style { let background = match kind { GitDiffLineKind::Addition => Some(Color::from_rgba8(45, 120, 72, 0.24)), GitDiffLineKind::Deletion => Some(Color::from_rgba8(170, 55, 64, 0.24)), GitDiffLineKind::Hunk => Some(Color::from_rgba8(55, 92, 150, 0.22)), GitDiffLineKind::Section => Some(Color::from_rgba8(255, 255, 255, 0.08)), GitDiffLineKind::Header | GitDiffLineKind::Context => None, }; match background { Some(background) => container::Style::default().background(background), None => container::Style::default(), } } fn git_change_color(kind: GitChangeKind) -> Color { match kind { GitChangeKind::Added => Color::from_rgb8(93, 186, 112), GitChangeKind::Modified => Color::from_rgb8(224, 174, 82), GitChangeKind::Deleted => Color::from_rgb8(224, 96, 104), } } fn staged_color() -> Color { Color::from_rgb8(93, 186, 112) } fn modal_backdrop_style(_: &Theme) -> container::Style { container::Style::default().background(Color::from_rgba8(0, 0, 0, 0.72)) } #[cfg(test)] mod tests { use super::*; #[test] fn split_diff_width_covers_the_longest_side_and_tabs() { let rows = [GitDiffRow::Pair { old: Some(GitDiffLine { kind: GitDiffLineKind::Deletion, old_number: Some(1), new_number: None, text: "short".into(), }), new: Some(GitDiffLine { kind: GitDiffLineKind::Addition, old_number: None, new_number: Some(1), text: "\tlong".into(), }), }]; assert_eq!(split_diff_width(&rows), 130.0); } }