Vectrum
Sign In
682 lines · 31.5 KB · Rust
//! T3 Code's git actions in the thread details panel: one quick action
//! that fits how the branch stands (Commit, Commit & push, Commit, push &
//! PR, Push, Push & create PR, Pull, Create PR), a menu with Commit, Push
//! and Create PR, the commit dialog, and the confirmation before pushing
//! to the default branch. Commit messages and pull requests are written by
//! the text generation model when left empty.

use std::path::{Path, PathBuf};
use std::time::Instant;

use gpui_kit::component::{
    Icon, IconName, WindowExt as _, checkbox::Checkbox, h_flex,
    input::EditorState,
    menu::{DropdownMenu as _, PopupMenuItem},
    notification::Notification,
    tooltip::Tooltip,
    v_flex,
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;

use super::Workspace;
use crate::git::{self, GitStatus, StatusFile};
use crate::ui::icons::Lucide;
use crate::ui::metal::{self, MetalButton, MetalStyled as _};

/// What a git action does.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GitAction {
    Commit,
    CommitPush,
    CommitPushPr,
    Push,
    PushPr,
    CreatePr,
    Pull,
}

impl GitAction {
    fn label(self) -> &'static str {
        match self {
            GitAction::Commit => "Commit",
            GitAction::CommitPush => "Commit & push",
            GitAction::CommitPushPr => "Commit, push & PR",
            GitAction::Push => "Push",
            GitAction::PushPr => "Push & create PR",
            GitAction::CreatePr => "Create PR",
            GitAction::Pull => "Pull",
        }
    }

    fn icon(self) -> Icon {
        match self {
            GitAction::Commit => Icon::new(Lucide::GitCommitHorizontal),
            GitAction::Push | GitAction::CommitPush => Icon::new(Lucide::CloudUpload),
            GitAction::Pull => Icon::new(Lucide::CloudDownload),
            GitAction::CommitPushPr | GitAction::PushPr | GitAction::CreatePr => Icon::new(Lucide::GitPullRequest),
        }
    }

    fn commits(self) -> bool {
        matches!(self, GitAction::Commit | GitAction::CommitPush | GitAction::CommitPushPr)
    }

    fn pushes(self) -> bool {
        matches!(self, GitAction::CommitPush | GitAction::CommitPushPr | GitAction::Push | GitAction::PushPr)
    }

    fn opens_pr(self) -> bool {
        matches!(self, GitAction::CommitPushPr | GitAction::PushPr | GitAction::CreatePr)
    }
}

/// The quick action: something to run, or a label with why there's
/// nothing to run.
#[derive(Clone, Debug, PartialEq)]
pub enum Quick {
    Run(GitAction),
    Hint { label: &'static str, hint: &'static str },
}

/// T3 Code's choice of quick action for how the branch stands.
pub fn quick_action(status: &GitStatus, pr_open: bool) -> Quick {
    let hint = |label, hint| Quick::Hint { label, hint };
    if status.detached() {
        return hint("Commit", "Create and checkout a ref before pushing or opening a PR.");
    }
    let pr_or_default = pr_open || status.on_default();
    if status.has_changes() {
        return Quick::Run(match () {
            _ if status.upstream.is_none() && !status.has_origin => GitAction::Commit,
            _ if pr_or_default => GitAction::CommitPush,
            _ => GitAction::CommitPushPr,
        });
    }
    if status.upstream.is_none() {
        if !status.has_origin {
            return hint("Push", "Add an \"origin\" remote before pushing.");
        }
        if status.ahead_of_default == 0 && pr_open {
            return hint("Commit", "Branch is up to date. No action needed.");
        }
        if status.ahead_of_default == 0 {
            return hint("Push", "No local commits to push.");
        }
        return Quick::Run(if pr_or_default { GitAction::Push } else { GitAction::PushPr });
    }
    if status.ahead > 0 && status.behind > 0 {
        return hint("Sync ref", "Branch has diverged from upstream. Rebase/merge first.");
    }
    if status.behind > 0 {
        return Quick::Run(GitAction::Pull);
    }
    if status.ahead > 0 {
        return Quick::Run(if pr_or_default { GitAction::Push } else { GitAction::PushPr });
    }
    if pr_open {
        return hint("Commit", "Branch is up to date. No action needed.");
    }
    if status.ahead_of_default > 0 && !status.on_default() {
        return Quick::Run(GitAction::CreatePr);
    }
    hint("Commit", "Branch is up to date. No action needed.")
}

/// Why a menu action can't run now, as T3 Code says it.
fn disabled_reason(action: GitAction, status: &GitStatus, pr_open: bool) -> Option<&'static str> {
    match action {
        GitAction::Commit if !status.has_changes() => Some("Worktree is clean. Make changes before committing."),
        GitAction::Push if status.detached() => Some("Detached HEAD: check out a branch before pushing."),
        GitAction::Push if status.has_changes() => Some("Commit or stash local changes before pushing."),
        GitAction::Push if status.behind > 0 => Some("Branch is behind upstream. Pull/rebase before pushing."),
        GitAction::Push if !status.has_origin => Some("Add an \"origin\" remote before pushing."),
        GitAction::Push if status.upstream.is_some() && status.ahead == 0 || status.upstream.is_none() && status.ahead_of_default == 0 => {
            Some("No local commits to push.")
        }
        GitAction::CreatePr if status.detached() => Some("Detached HEAD: check out a branch before creating a PR."),
        GitAction::CreatePr if status.has_changes() => Some("Commit local changes before creating a PR."),
        GitAction::CreatePr if !status.has_origin => Some("Add an \"origin\" remote before creating a PR."),
        GitAction::CreatePr if status.ahead_of_default == 0 => Some("No local commits to include in a PR."),
        GitAction::CreatePr if status.behind > 0 => Some("Branch is behind upstream. Pull/rebase before creating a PR."),
        GitAction::CreatePr if pr_open => Some("A pull request is already open."),
        _ => None,
    }
}

/// What a finished action reports.
struct Outcome {
    title: String,
    url: Option<String>,
}

/// Runs an action in `path`: commits (with `message`, or a generated one,
/// on a new feature branch when asked), pushes and opens a pull request.
/// Blocks; run it in the background.
fn run(
    path: &Path,
    action: GitAction,
    files: Option<Vec<String>>,
    message: String,
    feature_branch: bool,
) -> Result<Outcome, String> {
    let mut title = String::new();
    if action == GitAction::Pull {
        let status = git::status(path).unwrap_or_default();
        let before = git::git(path, &["rev-parse", "HEAD"]).unwrap_or_default();
        git::git(path, &["pull", "--ff-only"])?;
        let after = git::git(path, &["rev-parse", "HEAD"]).unwrap_or_default();
        let branch = status.branch.unwrap_or_default();
        return Ok(Outcome {
            title: if before == after {
                "Already up to date".into()
            } else {
                format!("Pulled: updated {branch} from {}", status.upstream.unwrap_or_default())
            },
            url: None,
        });
    }
    if action.commits() {
        if feature_branch {
            let fragment = crate::worktree::sanitize_fragment(message.lines().next().unwrap_or("update"));
            let mut branch = format!("feature/{fragment}");
            for n in 2.. {
                if git::git(path, &["rev-parse", "--verify", "-q", &format!("refs/heads/{branch}")]).is_err() {
                    break;
                }
                branch = format!("feature/{fragment}-{n}");
            }
            git::git(path, &["switch", "-c", &branch])?;
        }
        match &files {
            Some(files) => {
                let mut args = vec!["add", "-A", "--"];
                args.extend(files.iter().map(String::as_str));
                git::git(path, &args)?;
            }
            None => {
                git::git(path, &["add", "-A"])?;
            }
        }
        let message = if message.trim().is_empty() {
            let (summary, patch) = git::staged(path);
            let branch = git::status(path).and_then(|status| status.branch);
            crate::textgen::commit_message(branch.as_deref(), &summary, &patch).unwrap_or_else(|| "Update files".into())
        } else {
            message
        };
        git::git(path, &["commit", "-q", "-m", &message])?;
        let sha = git::git(path, &["rev-parse", "--short", "HEAD"])?.trim().to_string();
        title = format!("Committed {sha}");
    }
    if action.pushes() || action.opens_pr() {
        let status = git::status(path).unwrap_or_default();
        let branch = status.branch.clone().ok_or("Detached HEAD: check out a branch before pushing.")?;
        if action.pushes() || status.upstream.is_none() {
            if status.upstream.is_none() {
                git::git(path, &["push", "-q", "-u", "origin", &branch])?;
            } else {
                git::git(path, &["push", "-q"])?;
            }
            let sha = git::git(path, &["rev-parse", "--short", "HEAD"])?.trim().to_string();
            title = format!("Pushed {sha} to {branch}");
        }
        if action.opens_pr() {
            let gh = crate::ui::workspace::find_program(&["gh"]).ok_or("Install the GitHub CLI (gh) to create pull requests.")?;
            let base = status.default_branch.clone().unwrap_or_else(|| "main".into());
            let base_ref = format!("origin/{base}");
            let commits = git::git(path, &["log", "--oneline", &format!("{base_ref}..HEAD")]).unwrap_or_default();
            let stat = git::git(path, &["diff", "--stat", &format!("{base_ref}...HEAD")]).unwrap_or_default();
            let patch = git::git(path, &["diff", &format!("{base_ref}...HEAD")]).unwrap_or_default();
            let mut command = std::process::Command::new(gh);
            command.current_dir(path).args(["pr", "create", "--base", &base, "--head", &branch]);
            match crate::textgen::pull_request_content(&base, &branch, &commits, &stat, &patch) {
                Some((pr_title, body)) => command.args(["--title", &pr_title, "--body", &body]),
                None => command.arg("--fill"),
            };
            let output = command.stdin(std::process::Stdio::null()).output().map_err(|err| err.to_string())?;
            if !output.status.success() {
                return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
            }
            let url = String::from_utf8_lossy(&output.stdout).lines().rev().find(|line| line.starts_with("http")).map(str::to_string);
            let number = url.as_deref().and_then(|url| url.rsplit('/').next()).unwrap_or_default().to_string();
            return Ok(Outcome { title: format!("Created PR #{number}"), url });
        }
    }
    Ok(Outcome { title: if title.is_empty() { "Done".into() } else { title }, url: None })
}

impl Workspace {
    /// The folder the git actions work in: the open thread's worktree, or
    /// its project's checkout. `None` for a thread on another device.
    fn git_path(&self, cx: &App) -> Option<PathBuf> {
        let project = self.active_project()?;
        if project.environment.is_some() {
            return None;
        }
        Some(self.active_worktree(cx).unwrap_or_else(|| project.path.clone()))
    }

    /// Starts an action, after asking first when it would push to the
    /// default branch.
    pub(super) fn git_action(&mut self, action: GitAction, window: &mut Window, cx: &mut Context<Self>) {
        let Some((_, status)) = self.git_status.clone() else { return };
        if action == GitAction::Commit {
            self.open_commit_dialog(status, window, cx);
            return;
        }
        if (action.pushes() || action.opens_pr()) && status.on_default() {
            self.confirm_default_branch(action, &status, None, window, cx);
            return;
        }
        self.start_git_action(action, None, String::new(), false, window, cx);
    }

    fn start_git_action(
        &mut self,
        action: GitAction,
        files: Option<Vec<String>>,
        message: String,
        feature_branch: bool,
        window: &mut Window,
        cx: &mut Context<Self>,
    ) {
        let Some(path) = self.git_path(cx) else { return };
        let status = match action {
            GitAction::Pull => "Pulling latest changes...",
            _ if action.commits() && message.trim().is_empty() => "Generating commit message...",
            _ if action.commits() => "Committing...",
            _ if action.pushes() => "Pushing...",
            _ => "Creating pull request...",
        };
        self.git_running = Some((status, Instant::now()));
        cx.notify();
        let task = cx.background_executor().spawn({
            let path = path.clone();
            async move { run(&path, action, files, message, feature_branch) }
        });
        cx.spawn_in(window, async move |this, cx| {
            let result = task.await;
            let _ = this.update_in(cx, |this, window, cx| {
                this.git_running = None;
                match result {
                    Ok(outcome) => {
                        let mut notification = Notification::success(outcome.title.clone());
                        if let Some(url) = outcome.url {
                            notification = notification.content(move |_, _, _| {
                                let url = url.clone();
                                MetalButton::new("view-pr")
                                    .compact()
                                    .label("View PR")
                                    .on_click(move |_, _, _| crate::ui::workspace::open_url(&url))
                                    .into_any_element()
                            });
                        }
                        window.push_notification(notification, cx);
                    }
                    Err(err) => window.push_notification(Notification::error(err).title("Action failed"), cx),
                }
                this.poll_git(cx);
                cx.notify();
            });
        })
        .detach();
    }

    /// T3 Code's question before pushing to the default branch: go on, or
    /// check out a feature branch and run the same action there.
    fn confirm_default_branch(
        &mut self,
        action: GitAction,
        status: &GitStatus,
        commit: Option<(Option<Vec<String>>, String)>,
        window: &mut Window,
        cx: &mut Context<Self>,
    ) {
        let branch = status.branch.clone().unwrap_or_default();
        let with_commit = action.commits() || commit.is_some();
        let (title, description, continue_label): (&str, String, String) = match (action.opens_pr(), with_commit) {
            (false, true) => (
                "Commit & push to default ref?",
                "This action will commit and push changes".into(),
                format!("Commit & push to {branch}"),
            ),
            (false, false) => ("Push to default ref?", "This action will push local commits".into(), format!("Push to {branch}")),
            (true, true) => (
                "Commit, push & create PR from default ref?",
                "This action will commit, push, and create a PR".into(),
                "Commit, push & create PR".into(),
            ),
            (true, false) => (
                "Push & create PR from default ref?",
                "This action will push local commits and create a PR".into(),
                "Push & create PR".into(),
            ),
        };
        let description =
            format!("{description} on \"{branch}\". You can continue on this ref or create a feature ref and run the same action there.");
        let workspace = cx.entity().downgrade();
        let (files, message) = commit.unwrap_or((None, String::new()));
        window.open_dialog(cx, move |dialog, _, cx| {
            let p = metal::palette(cx);
            let (stay, feature) = (workspace.clone(), workspace.clone());
            let (stay_files, feature_files) = (files.clone(), files.clone());
            let (stay_message, feature_message) = (message.clone(), message.clone());
            dialog
                .w(px(576.))
                .title(title)
                .child(div().text_size(px(13.)).text_color(p.ink2).child(description.clone()))
                .footer(
                    h_flex()
                        .w_full()
                        .justify_end()
                        .gap(px(8.))
                        .child(MetalButton::new("default-abort").compact().label("Abort").on_click(|_, window, cx| window.close_dialog(cx)))
                        .child(MetalButton::new("default-continue").compact().label(continue_label.clone()).on_click(
                            move |_, window, cx| {
                                window.close_dialog(cx);
                                let (files, message) = (stay_files.clone(), stay_message.clone());
                                let _ = stay.update(cx, |this, cx| this.start_git_action(action, files, message, false, window, cx));
                            },
                        ))
                        .child(
                            MetalButton::new("default-feature")
                                .primary()
                                .compact()
                                .label("Check out feature branch & continue")
                                .on_click(move |_, window, cx| {
                                    window.close_dialog(cx);
                                    let (files, message) = (feature_files.clone(), feature_message.clone());
                                    let _ = feature.update(cx, |this, cx| this.start_git_action(action, files, message, true, window, cx));
                                }),
                        ),
                )
        });
    }

    /// T3 Code's commit dialog: the branch, the files (pick which with
    /// Edit), and an optional message.
    fn open_commit_dialog(&mut self, status: GitStatus, window: &mut Window, cx: &mut Context<Self>) {
        let workspace = cx.entity().downgrade();
        let dialog = cx.new(|cx| CommitDialog::new(status, workspace, window, cx));
        window.open_dialog(cx, move |modal, _, _| {
            let dialog = dialog.clone();
            modal.w(px(560.)).title("Commit changes").child(dialog)
        });
    }

    /// The git actions' row in the thread details panel: the quick action
    /// and the menu beside it, or what's running and for how long.
    pub(super) fn render_git_actions(&self, cx: &mut Context<Self>) -> AnyElement {
        let p = metal::palette(cx);
        let Some((_, status)) = self.git_status.clone() else {
            return super::Workspace::panel_row(Lucide::GitCommitHorizontal, "Commit", p).text_color(p.ink3).into_any_element();
        };
        let pr_open = self.active.as_ref().and_then(|session| session.read(cx).pull_request.clone()).is_some_and(|pr| pr.is_open());
        if let Some((label, started)) = self.git_running {
            return h_flex()
                .h(px(32.))
                .px(px(10.))
                .gap(px(10.))
                .text_size(px(13.))
                .text_color(p.ink2)
                .child(metal::led("git-running", metal::Led::Live, true, p))
                .child(div().flex_1().min_w_0().truncate().child(label))
                .child(div().text_size(px(12.)).text_color(p.ink3).child(elapsed(started)))
                .into_any_element();
        }
        let quick = quick_action(&status, pr_open);
        let (icon, label, hint, action) = match &quick {
            Quick::Run(action) => (action.icon(), action.label(), None, Some(*action)),
            Quick::Hint { label, hint } => {
                let icon = match *label {
                    "Commit" => Icon::new(Lucide::GitCommitHorizontal),
                    "Push" => Icon::new(Lucide::CloudUpload),
                    _ => Icon::new(IconName::Info),
                };
                (icon, *label, Some(*hint), None)
            }
        };
        let workspace = cx.entity().downgrade();
        let menu_status = status.clone();
        h_flex()
            .w_full()
            .rounded(px(10.))
            .hover(|style| style.bg(p.row_hover))
            .child(
                h_flex()
                    .id("git-quick-action")
                    .flex_1()
                    .min_w_0()
                    .h(px(32.))
                    .px(px(10.))
                    .gap(px(10.))
                    .text_size(px(13.))
                    .font_weight(FontWeight::MEDIUM)
                    .text_color(if action.is_some() { p.ink.opacity(0.8) } else { p.ink3 })
                    .cursor_pointer()
                    .child(icon.size(px(16.)).text_color(p.ink3))
                    .child(div().truncate().child(label))
                    .when_some(hint, |this, hint| {
                        this.tooltip(move |window, cx| Tooltip::new(hint).build(window, cx))
                            .on_click(move |_, window, cx| window.push_notification(Notification::info(hint).title(label), cx))
                    })
                    .when_some(action, |this, action| {
                        this.on_click(cx.listener(move |this, _, window, cx| this.git_action(action, window, cx)))
                    }),
            )
            .child(div().w(px(1.)).h(px(16.)).bg(p.rule))
            .child(
                MetalButton::new("git-action-options")
                    .ghost()
                    .mini()
                    .w(px(32.))
                    .h(px(32.))
                    .icon(IconName::ChevronDown)
                    .tooltip("Git action options")
                    .dropdown_menu_with_anchor(Anchor::TopRight, move |mut menu, _, _| {
                        for action in [GitAction::Commit, GitAction::Push, GitAction::CreatePr] {
                            if action != GitAction::Commit && !menu_status.has_origin {
                                continue;
                            }
                            if action == GitAction::CreatePr && pr_open {
                                continue;
                            }
                            let reason = disabled_reason(action, &menu_status, pr_open);
                            let workspace = workspace.clone();
                            menu = menu.item(
                                PopupMenuItem::new(action.label())
                                    .icon(action.icon())
                                    .disabled(reason.is_some())
                                    .on_click(move |_, window, cx| {
                                        let _ = workspace.update(cx, |this, cx| this.git_action(action, window, cx));
                                    }),
                            );
                        }
                        if menu_status.detached() {
                            menu = menu.separator().label("Detached HEAD: create and check out a branch to enable push and pull request actions.");
                        } else if menu_status.behind > 0 {
                            menu = menu.separator().label("Behind upstream. Pull/rebase first.");
                        }
                        menu
                    }),
            )
            .into_any_element()
    }
}

fn elapsed(started: Instant) -> String {
    let secs = started.elapsed().as_secs();
    if secs < 60 { format!("{secs}s") } else { format!("{}m {}s", secs / 60, secs % 60) }
}

/// The commit dialog's state.
struct CommitDialog {
    status: GitStatus,
    selected: Vec<bool>,
    editing: bool,
    message: Entity<EditorState>,
    workspace: WeakEntity<Workspace>,
}

impl CommitDialog {
    fn new(status: GitStatus, workspace: WeakEntity<Workspace>, window: &mut Window, cx: &mut Context<Self>) -> Self {
        let message = cx.new(|cx| {
            crate::ui::markdown_input::composer_state(EditorState::new(window, cx), false).placeholder("Leave empty to auto-generate")
        });
        let selected = vec![true; status.files.len()];
        Self { status, selected, editing: false, message, workspace }
    }

    fn picked(&self) -> Vec<&StatusFile> {
        self.status.files.iter().zip(&self.selected).filter(|(_, on)| **on).map(|(file, _)| file).collect()
    }

    fn commit(&mut self, feature_branch: bool, window: &mut Window, cx: &mut Context<Self>) {
        let all = self.selected.iter().all(|on| *on);
        let files = (!all).then(|| self.picked().into_iter().map(|file| file.path.clone()).collect::<Vec<_>>());
        let message = self.message.read(cx).value().to_string();
        window.close_dialog(cx);
        let _ = self.workspace.update(cx, |this, cx| {
            this.start_git_action(GitAction::Commit, files, message, feature_branch, window, cx)
        });
    }
}

impl Render for CommitDialog {
    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        let p = metal::palette(cx);
        let picked = self.picked();
        let (added, removed) = picked.iter().fold((0, 0), |(a, r), file| (a + file.added.unwrap_or(0), r + file.removed.unwrap_or(0)));
        let count = picked.len();
        let total = self.status.files.len();
        let on_default = self.status.on_default();
        let all = self.selected.iter().all(|on| *on);
        v_flex()
            .gap(px(12.))
            .child(
                div()
                    .text_size(px(13.))
                    .text_color(p.ink2)
                    .child("Review and confirm your commit. Leave the message blank to auto-generate one."),
            )
            .child(
                v_flex()
                    .p(px(12.))
                    .gap(px(8.))
                    .rounded(px(14.))
                    .well(p)
                    .text_size(px(13.))
                    .child(
                        h_flex()
                            .gap(px(8.))
                            .child(div().w(px(48.)).text_color(p.ink3).child("Branch"))
                            .child(div().flex_1().truncate().text_color(p.ink).child(self.status.branch.clone().unwrap_or_else(|| "(detached HEAD)".into())))
                            .when(on_default, |this| this.child(div().text_size(px(12.)).text_color(p.amber_ink).child("Default branch"))),
                    )
                    .child(
                        h_flex()
                            .gap(px(8.))
                            .child(div().w(px(48.)).text_color(p.ink3).child("Files"))
                            .child(div().flex_1().text_color(p.ink2).when(count < total, |this| this.child(format!("({count} of {total})"))))
                            .when(self.editing, |this| {
                                this.child(
                                    Checkbox::new("commit-all")
                                        .checked(all)
                                        .on_click(cx.listener(move |this, _, _, cx| {
                                            let on = !all;
                                            this.selected.iter_mut().for_each(|selected| *selected = on);
                                            cx.notify();
                                        })),
                                )
                            })
                            .child(
                                MetalButton::new("commit-edit-files")
                                    .ghost()
                                    .mini()
                                    .label(if self.editing { "Done" } else { "Edit" })
                                    .on_click(cx.listener(|this, _, _, cx| {
                                        this.editing = !this.editing;
                                        cx.notify();
                                    })),
                            ),
                    )
                    .child(
                        v_flex()
                            .id("commit-files")
                            .max_h(px(176.))
                            .overflow_y_scroll()
                            .font_family(metal::MONO)
                            .text_size(px(12.))
                            .when(total == 0, |this| this.child(div().text_color(p.ink3).child("none")))
                            .children(self.status.files.iter().enumerate().map(|(ix, file)| {
                                let on = self.selected[ix];
                                h_flex()
                                    .gap(px(8.))
                                    .py(px(2.))
                                    .when(self.editing, |this| {
                                        this.child(Checkbox::new(("commit-file", ix)).checked(on).on_click(cx.listener(move |this, _, _, cx| {
                                            this.selected[ix] = !this.selected[ix];
                                            cx.notify();
                                        })))
                                    })
                                    .child(div().flex_1().min_w_0().truncate().text_color(if on { p.ink } else { p.ink3 }).child(file.path.clone()))
                                    .child(if on {
                                        h_flex()
                                            .gap(px(4.))
                                            .child(div().text_color(metal::diff_ink(p).added).child(format!("+{}", file.added.unwrap_or(0))))
                                            .child(div().text_color(metal::diff_ink(p).removed).child(format!("-{}", file.removed.unwrap_or(0))))
                                            .into_any_element()
                                    } else {
                                        div().text_color(p.ink3).child("Excluded").into_any_element()
                                    })
                            })),
                    )
                    .child(
                        h_flex()
                            .justify_end()
                            .gap(px(4.))
                            .font_family(metal::MONO)
                            .text_size(px(12.))
                            .child(div().text_color(metal::diff_ink(p).added).child(format!("+{added}")))
                            .child(div().text_color(p.ink3).child("/"))
                            .child(div().text_color(metal::diff_ink(p).removed).child(format!("-{removed}"))),
                    ),
            )
            .child(
                v_flex()
                    .gap(px(6.))
                    .child(div().text_size(px(12.)).font_weight(FontWeight::MEDIUM).text_color(p.ink2).child("Commit message (optional)"))
                    .child(
                        div()
                            .px(px(10.))
                            .py(px(6.))
                            .rounded(px(10.))
                            .well(p)
                            .text_size(px(13.))
                            .child(crate::ui::markdown_input::MarkdownInput::new(&self.message).rows(3, 8)),
                    ),
            )
            .child(
                h_flex()
                    .justify_end()
                    .gap(px(8.))
                    .child(MetalButton::new("commit-cancel").compact().label("Cancel").on_click(|_, window, cx| window.close_dialog(cx)))
                    .child(
                        MetalButton::new("commit-new-branch")
                            .compact()
                            .label("Commit on new branch")
                            .disabled(count == 0)
                            .on_click(cx.listener(|this, _, window, cx| this.commit(true, window, cx))),
                    )
                    .child(
                        MetalButton::new("commit-confirm")
                            .primary()
                            .compact()
                            .label("Commit")
                            .disabled(count == 0)
                            .on_click(cx.listener(|this, _, window, cx| this.commit(false, window, cx))),
                    ),
            )
    }
}