//! 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(p.green_ink).child(format!("+{}", file.added.unwrap_or(0))))
.child(div().text_color(p.red).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(p.green_ink).child(format!("+{added}")))
.child(div().text_color(p.ink3).child("/"))
.child(div().text_color(p.red).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))),
),
)
}
}