//! 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, } /// 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>, message: String, feature_branch: bool, ) -> Result { 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 { 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) { 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>, message: String, feature_branch: bool, window: &mut Window, cx: &mut Context, ) { 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>, String)>, window: &mut Window, cx: &mut Context, ) { 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) { 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) -> 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, editing: bool, message: Entity, workspace: WeakEntity, } impl CommitDialog { fn new(status: GitStatus, workspace: WeakEntity, window: &mut Window, cx: &mut Context) -> 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) { let all = self.selected.iter().all(|on| *on); let files = (!all).then(|| self.picked().into_iter().map(|file| file.path.clone()).collect::>()); 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) -> 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))), ), ) } }