Vectrum
Sign In

Git actions in the thread details panel, like T3 Code's

The panel's Commit row becomes T3 Code's git actions control. Its quick
action fits how the branch stands (Commit, Commit & push, Commit, push &
PR, Push, Push & create PR, Pull, Create PR, or why there's nothing to do),
and the menu beside it offers Commit, Push and Create PR with T3's reasons
when one can't run. Commit opens T3's dialog: the branch (marked when it's
the default one), the files with Edit to leave some out, an optional
message, and Commit or Commit on new branch. Pushing to the default branch
asks first, with Check out feature branch & continue. Empty commit messages
and pull requests are written by the text generation model with T3's
prompts; pull requests open with gh. The app runs git itself now instead of
asking the agent.

Ctrl+O opens the thread in the favorite editor, as in T3 Code, and the
browser moves to Ctrl+Shift+J (T3's preview toggle), which frees
Ctrl+Shift+E for the reasoning menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JjohannSocommitted 1 parent 4154bf3commitf3585a5

Showing 6 changed files with 907 additions and 67 deletions.

+122−0src/git.rsView File
@@ -370,3 +370,125 @@ mod tests {
370370 assert_eq!(kinds, [LineKind::Hunk, LineKind::Context, LineKind::Removed, LineKind::Added]);
371371 }
372372 }
373+
374+ /// One changed file, for the commit dialog.
375+ #[derive(Clone, Debug, PartialEq)]
376+ pub struct StatusFile {
377+ pub path: String,
378+ pub added: Option<u64>,
379+ pub removed: Option<u64>,
380+ }
381+
382+ /// What T3 Code's git actions need to know about a checkout.
383+ #[derive(Clone, Debug, Default, PartialEq)]
384+ pub struct GitStatus {
385+ pub branch: Option<String>,
386+ pub files: Vec<StatusFile>,
387+ /// The branch it tracks, like `origin/main`.
388+ pub upstream: Option<String>,
389+ pub ahead: u32,
390+ pub behind: u32,
391+ pub has_origin: bool,
392+ /// The repository's default branch, like `main`.
393+ pub default_branch: Option<String>,
394+ /// Commits on the branch that the default branch doesn't have.
395+ pub ahead_of_default: u32,
396+ }
397+
398+ impl GitStatus {
399+ pub fn detached(&self) -> bool {
400+ self.branch.is_none()
401+ }
402+
403+ pub fn has_changes(&self) -> bool {
404+ !self.files.is_empty()
405+ }
406+
407+ pub fn on_default(&self) -> bool {
408+ self.branch.is_some() && self.branch == self.default_branch
409+ }
410+ }
411+
412+ /// Reads a checkout's branch, changed files and how it stands against its
413+ /// upstream and the default branch. `None` outside a repository.
414+ pub fn status(path: &Path) -> Option<GitStatus> {
415+ git(path, &["rev-parse", "--is-inside-work-tree"]).ok()?;
416+ let branch = git(path, &["symbolic-ref", "--short", "-q", "HEAD"]).ok().map(|out| out.trim().to_string()).filter(|b| !b.is_empty());
417+ let mut files: Vec<StatusFile> = Vec::new();
418+ let porcelain = git(path, &["status", "--porcelain", "-z", "--untracked-files=all"]).unwrap_or_default();
419+ let mut entries = porcelain.split('\0').filter(|entry| !entry.is_empty());
420+ while let Some(entry) = entries.next() {
421+ if entry.len() < 4 {
422+ continue;
423+ }
424+ let code = &entry[..2];
425+ let file = entry[3..].to_string();
426+ // A rename lists its old path next.
427+ if code.starts_with('R') || code.starts_with('C') {
428+ entries.next();
429+ }
430+ files.push(StatusFile { path: file, added: None, removed: None });
431+ }
432+ let has_head = git(path, &["rev-parse", "--verify", "-q", "HEAD"]).is_ok();
433+ if has_head {
434+ for line in git(path, &["diff", "--numstat", "HEAD"]).unwrap_or_default().lines() {
435+ let mut fields = line.splitn(3, '\t');
436+ let (Some(plus), Some(minus), Some(file)) = (fields.next(), fields.next(), fields.next()) else { continue };
437+ if let Some(entry) = files.iter_mut().find(|entry| entry.path == file) {
438+ entry.added = plus.parse().ok();
439+ entry.removed = minus.parse().ok();
440+ }
441+ }
442+ }
443+ for entry in files.iter_mut().filter(|entry| entry.added.is_none()) {
444+ let full = path.join(&entry.path);
445+ if full.metadata().is_ok_and(|meta| meta.is_file() && meta.len() <= 1_048_576)
446+ && let Ok(bytes) = std::fs::read(&full)
447+ && !bytes.contains(&0)
448+ {
449+ entry.added = Some(bytes.iter().filter(|&&byte| byte == b'\n').count() as u64);
450+ entry.removed = Some(0);
451+ }
452+ }
453+ let upstream = git(path, &["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"])
454+ .ok()
455+ .map(|out| out.trim().to_string())
456+ .filter(|upstream| !upstream.is_empty());
457+ let (mut ahead, mut behind) = (0, 0);
458+ if upstream.is_some()
459+ && let Ok(counts) = git(path, &["rev-list", "--left-right", "--count", "HEAD...@{upstream}"])
460+ {
461+ let mut counts = counts.split_whitespace().map(|count| count.parse().unwrap_or(0));
462+ ahead = counts.next().unwrap_or(0);
463+ behind = counts.next().unwrap_or(0);
464+ }
465+ let has_origin = git(path, &["remote"]).is_ok_and(|remotes| remotes.lines().any(|remote| remote == "origin"));
466+ let default_branch = git(path, &["symbolic-ref", "--short", "-q", "refs/remotes/origin/HEAD"])
467+ .ok()
468+ .map(|name| name.trim().trim_start_matches("origin/").to_string())
469+ .filter(|name| !name.is_empty())
470+ .or_else(|| {
471+ ["main", "master"]
472+ .into_iter()
473+ .find(|name| git(path, &["rev-parse", "--verify", "-q", &format!("refs/heads/{name}")]).is_ok())
474+ .map(str::to_string)
475+ });
476+ let ahead_of_default = match (&default_branch, has_head) {
477+ (Some(default), true) => {
478+ let base = if has_origin && git(path, &["rev-parse", "--verify", "-q", &format!("refs/remotes/origin/{default}")]).is_ok() {
479+ format!("origin/{default}")
480+ } else {
481+ default.clone()
482+ };
483+ git(path, &["rev-list", "--count", &format!("{base}..HEAD")]).ok().and_then(|out| out.trim().parse().ok()).unwrap_or(0)
484+ }
485+ _ => 0,
486+ };
487+ Some(GitStatus { branch, files, upstream, ahead, behind, has_origin, default_branch, ahead_of_default })
488+ }
489+
490+ /// What's staged, as the commit message prompt reads it: the file list and
491+ /// the patch.
492+ pub fn staged(path: &Path) -> (String, String) {
493+ (git(path, &["diff", "--cached", "--stat"]).unwrap_or_default(), git(path, &["diff", "--cached"]).unwrap_or_default())
494+ }
+3−2src/main.rsView File
@@ -34,6 +34,7 @@ gpui_kit::actions!(
3434 [
3535 NewThread,
3636 OpenProject,
37+ OpenInEditor,
3738 Interrupt,
3839 ToggleSidebar,
3940 ToggleProjectPanel,
@@ -105,9 +106,9 @@ fn main() {
105106 KeyBinding::new("ctrl-alt-a", SelectTheme, None),
106107 KeyBinding::new("ctrl-alt-shift-a", CycleAppearance, None),
107108 // The app's own.
108- KeyBinding::new("ctrl-o", OpenProject, None),
109+ KeyBinding::new("ctrl-o", OpenInEditor, None),
109110 KeyBinding::new("ctrl-shift-b", ToggleProjectPanel, None),
110- KeyBinding::new("ctrl-shift-e", ToggleBrowser, None),
111+ KeyBinding::new("ctrl-shift-j", ToggleBrowser, None),
111112 KeyBinding::new("ctrl-,", OpenSettings, None),
112113 KeyBinding::new("ctrl-q", Quit, None),
113114 KeyBinding::new("escape", Interrupt, Some("ChatView")),
+77−5src/textgen.rsView File
@@ -51,15 +51,87 @@ pub fn branch_name(message: &str, naming: BranchNaming, instructions: &str) -> O
5151 if naming == BranchNaming::Custom && !instructions.trim().is_empty() {
5252 prompt.push_str(&format!("\n\nAdditional instructions:\n{}", instructions.trim()));
5353 }
54- let reply = generate(&prompt)?;
55- // The reply is a JSON object, possibly wrapped in a code fence.
56- let start = reply.find('{')?;
57- let end = reply.rfind('}')?;
58- let value: serde_json::Value = serde_json::from_str(reply.get(start..=end)?).ok()?;
54+ let value = json_reply(&generate(&prompt)?)?;
5955 let branch = value.get("branch")?.as_str()?.trim().to_string();
6056 (!branch.is_empty()).then_some(branch)
6157 }
6258
59+ /// A commit message for what's staged, as T3 Code asks for one: an
60+ /// imperative subject of at most 72 characters and an optional body.
61+ pub fn commit_message(branch: Option<&str>, staged_summary: &str, staged_patch: &str) -> Option<String> {
62+ let prompt = [
63+ "You write concise git commit messages.",
64+ "Return a JSON object with keys: subject, body.",
65+ "Rules:",
66+ "- subject must be imperative, <= 72 chars, and no trailing period",
67+ "- body can be empty string or short bullet points",
68+ "- capture the primary user-visible or developer-visible change",
69+ "",
70+ &format!("Branch: {}", branch.unwrap_or("(detached)")),
71+ "",
72+ "Staged files:",
73+ &limit(staged_summary, 6_000),
74+ "",
75+ "Staged patch:",
76+ &limit(staged_patch, 40_000),
77+ ]
78+ .join("\n");
79+ let value = json_reply(&generate(&prompt)?)?;
80+ let subject = value.get("subject")?.as_str()?.trim().trim_end_matches('.').to_string();
81+ if subject.is_empty() {
82+ return None;
83+ }
84+ let body = value.get("body").and_then(|body| body.as_str()).unwrap_or_default().trim().to_string();
85+ Some(if body.is_empty() { subject } else { format!("{subject}\n\n{body}") })
86+ }
87+
88+ /// A pull request's title and body for a branch, as T3 Code asks for them.
89+ pub fn pull_request_content(base: &str, head: &str, commits: &str, diff_stat: &str, diff_patch: &str) -> Option<(String, String)> {
90+ let prompt = [
91+ "You write source control change request content.",
92+ "Return a JSON object with keys: title, body.",
93+ "Rules:",
94+ "- title should be concise and specific",
95+ "- body must be markdown and include headings '## Summary' and '## Testing'",
96+ "- under Summary, provide short bullet points",
97+ "- under Testing, include bullet points with concrete checks or 'Not run' where appropriate",
98+ "",
99+ &format!("Base branch: {base}"),
100+ &format!("Head branch: {head}"),
101+ "",
102+ "Commits:",
103+ &limit(commits, 12_000),
104+ "",
105+ "Diff stat:",
106+ &limit(diff_stat, 12_000),
107+ "",
108+ "Diff patch:",
109+ &limit(diff_patch, 40_000),
110+ ]
111+ .join("\n");
112+ let value = json_reply(&generate(&prompt)?)?;
113+ let title = value.get("title")?.as_str()?.trim().to_string();
114+ let body = value.get("body").and_then(|body| body.as_str()).unwrap_or_default().to_string();
115+ (!title.is_empty()).then_some((title, body))
116+ }
117+
118+ /// The JSON object in a model's reply, which may sit in a code fence.
119+ fn json_reply(reply: &str) -> Option<serde_json::Value> {
120+ let start = reply.find('{')?;
121+ let end = reply.rfind('}')?;
122+ serde_json::from_str(reply.get(start..=end)?).ok()
123+ }
124+
125+ /// At most `max` characters of `text`.
126+ fn limit(text: &str, max: usize) -> String {
127+ if text.chars().count() <= max {
128+ return text.to_string();
129+ }
130+ let mut cut: String = text.chars().take(max).collect();
131+ cut.push_str("\n[truncated]");
132+ cut
133+ }
134+
63135 /// Runs `prompt` through the text generation model and returns its reply.
64136 fn generate(prompt: &str) -> Option<String> {
65137 let (key, reasoning) = settings::get(|settings| (settings.text_model.clone(), settings.text_reasoning.clone()));
+2−0src/ui/icons.rsView File
@@ -104,6 +104,8 @@ gpui_kit::assets::icon_assets!(ExtraIcons, [
104104 GitPullRequestClosed,
105105 GitPullRequestDraft,
106106 ListFilter,
107+ CloudUpload,
108+ CloudDownload,
107109 ]);
108110
109111 /// The app's asset source: the extra icons, then gpui-component's defaults.
+21−60src/ui/workspace.rsView File
@@ -40,11 +40,12 @@ use crate::ui::settings::{SettingsEvent, SettingsView};
4040 use crate::worktree::{self, Worktree, WorktreePlan};
4141 use crate::{
4242 CopyThreadReference, CycleAppearance, FocusSearch, NewThread, NewThreadLocal, NewThreadWithoutProject, NextThread,
43- OpenProject, OpenSettings, OpenUsage, PinThread, PreviousThread, SelectTheme, SettleThread, ToggleBrowser,
43+ OpenInEditor, OpenProject, OpenSettings, OpenUsage, PinThread, PreviousThread, SelectTheme, SettleThread, ToggleBrowser,
4444 ToggleCommandPalette, ToggleProjectPanel, ToggleRightPanel, ToggleSidebar, UndoThreadAction,
4545 };
4646
4747 mod actions;
48+ mod git_actions;
4849 mod header;
4950 mod palette;
5051 mod remote;
@@ -193,6 +194,10 @@ struct Activity {
193194 }
194195
195196 pub struct Workspace {
197+ /// The open thread's checkout as the git actions see it.
198+ git_status: Option<(PathBuf, git::GitStatus)>,
199+ /// The git action running, and since when.
200+ git_running: Option<(&'static str, Instant)>,
196201 /// The row whose hover card stays hidden while its menu is open.
197202 hover_card_hidden: Option<EntityId>,
198203 projects: Vec<Project>,
@@ -340,6 +345,8 @@ impl Workspace {
340345 let editor = editors.iter().position(|app| Some(app.name) == saved_editor.as_deref()).unwrap_or(0);
341346 let now = Instant::now();
342347 let mut workspace = Self {
348+ git_status: None,
349+ git_running: None,
343350 hover_card_hidden: None,
344351 projects: Vec::new(),
345352 chats: HashMap::new(),
@@ -599,14 +606,22 @@ impl Workspace {
599606 self.projects.iter().filter(|project| project.environment.is_none()).map(|project| project.path.clone()).collect();
600607 let worktree = self.active_worktree(cx);
601608 paths.extend(worktree.clone());
609+ // The open thread's checkout, for the git actions.
610+ let status_path = self
611+ .active_project()
612+ .filter(|project| project.environment.is_none())
613+ .map(|project| worktree.clone().unwrap_or_else(|| project.path.clone()));
602614 let task = cx.background_executor().spawn(async move {
603- paths.into_iter().map(|path| (git::read_info(&path), path)).collect::<Vec<_>>()
615+ let infos = paths.into_iter().map(|path| (git::read_info(&path), path)).collect::<Vec<_>>();
616+ let status = status_path.and_then(|path| git::status(&path).map(|status| (path, status)));
617+ (infos, status)
604618 });
605619 cx.spawn(async move |this, cx| {
606- let infos = task.await;
620+ let (infos, status) = task.await;
607621 let _ = this.update(cx, |this, cx| {
608622 this.git_busy = false;
609- let mut changed = false;
623+ let mut changed = this.git_status != status;
624+ this.git_status = status;
610625 for (info, path) in infos {
611626 if Some(&path) == worktree.as_ref() {
612627 let info = info.unwrap_or_default();
@@ -1546,13 +1561,6 @@ impl Workspace {
15461561 .detach();
15471562 }
15481563
1549- /// Hands a git chore to the agent in the open thread.
1550- fn ask_agent(&mut self, prompt: &str, cx: &mut Context<Self>) {
1551- if let Some(session) = &self.active {
1552- session.update(cx, |session, cx| session.send_message(prompt.to_string(), Vec::new(), cx));
1553- }
1554- }
1555-
15561564 // ---- what the settings view reads and changes ----------------------
15571565
15581566 fn open_settings(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@@ -1760,7 +1768,6 @@ impl Workspace {
17601768 let current = git.branch.clone();
17611769 let workspace = cx.entity().downgrade();
17621770 let branch_label = git.branch.clone().unwrap_or_else(|| "Detached HEAD".into());
1763- let has_changes = git.added + git.removed > 0;
17641771 let branch_row = if worktree.is_some() {
17651772 Self::panel_row(Lucide::FolderGit2, branch_label, p).child(metal::tag("Worktree", p)).into_any_element()
17661773 } else if remote {
@@ -1802,7 +1809,7 @@ impl Workspace {
18021809 v_flex()
18031810 .gap(px(2.))
18041811 .child(branch_row)
1805- .child(self.render_commit_row(has_thread && has_changes, cx))
1812+ .when(has_thread, |this| this.child(self.render_git_actions(cx)))
18061813 .child(
18071814 Self::panel_row(Lucide::FileDiff, "Changes", p)
18081815 .child(
@@ -1886,53 +1893,6 @@ impl Workspace {
18861893 .into_any_element()
18871894 }
18881895
1889- fn render_commit_row(&self, enabled: bool, cx: &mut Context<Self>) -> impl IntoElement {
1890- const COMMIT: &str = "Commit all current changes in this repository with a concise, descriptive commit message.";
1891- const COMMIT_AND_PUSH: &str = "Commit all current changes in this repository with a concise, descriptive commit message, then push the branch to its remote.";
1892- const PULL_REQUEST: &str = "Commit all current changes in this repository with a concise, descriptive commit message, push the branch, and open a pull request for it with `gh pr create`.";
1893- let p = metal::palette(cx);
1894- let workspace = cx.entity().downgrade();
1895- h_flex()
1896- .gap(px(2.))
1897- .child(
1898- Self::panel_row(Lucide::GitCommitHorizontal, "Commit", p)
1899- .flex_1()
1900- .id("commit")
1901- .map(|this| {
1902- if enabled {
1903- this.cursor_pointer()
1904- .hover(|style| style.bg(p.row_hover))
1905- .on_click(cx.listener(|this, _, _, cx| this.ask_agent(COMMIT, cx)))
1906- } else {
1907- this.opacity(0.45)
1908- }
1909- })
1910- .tooltip(|window, cx| {
1911- gpui_kit::component::tooltip::Tooltip::new("Ask the agent to commit the changes")
1912- .build(window, cx)
1913- }),
1914- )
1915- .child(
1916- MetalButton::new("commit-menu")
1917- .ghost()
1918- .mini()
1919- .icon(IconName::ChevronDown)
1920- .disabled(!enabled)
1921- .dropdown_menu_with_anchor(Anchor::TopRight, move |menu, _, _| {
1922- let ask = |prompt: &'static str| {
1923- let workspace = workspace.clone();
1924- move |_: &ClickEvent, _: &mut Window, cx: &mut App| {
1925- let _ = workspace.update(cx, |this, cx| this.ask_agent(prompt, cx));
1926- }
1927- };
1928- menu.min_w(px(240.))
1929- .item(PopupMenuItem::new("Ask the agent to commit").on_click(ask(COMMIT)))
1930- .item(PopupMenuItem::new("Ask the agent to commit and push").on_click(ask(COMMIT_AND_PUSH)))
1931- .item(PopupMenuItem::new("Ask the agent to open a pull request").on_click(ask(PULL_REQUEST)))
1932- }),
1933- )
1934- }
1935-
19361896 fn render_empty_sheet(&self, cx: &mut Context<Self>) -> impl IntoElement {
19371897 let p = metal::palette(cx);
19381898 v_flex()
@@ -2085,6 +2045,7 @@ impl Render for Workspace {
20852045 .on_modifiers_changed(cx.listener(|this, event: &ModifiersChangedEvent, window, cx| {
20862046 this.modifiers_changed(event, window, cx)
20872047 }))
2048+ .on_action(cx.listener(|this, _: &OpenInEditor, window, cx| this.open_in_editor(this.editor, window, cx)))
20882049 .on_action(cx.listener(|this, _: &OpenProject, window, cx| {
20892050 this.prompt_for_project(window, cx)
20902051 }))
+682−0src/ui/workspace/git_actions.rsaddedView File
@@ -0,0 +1,682 @@
1+ //! T3 Code's git actions in the thread details panel: one quick action
2+ //! that fits how the branch stands (Commit, Commit & push, Commit, push &
3+ //! PR, Push, Push & create PR, Pull, Create PR), a menu with Commit, Push
4+ //! and Create PR, the commit dialog, and the confirmation before pushing
5+ //! to the default branch. Commit messages and pull requests are written by
6+ //! the text generation model when left empty.
7+
8+ use std::path::{Path, PathBuf};
9+ use std::time::Instant;
10+
11+ use gpui_kit::component::{
12+ Icon, IconName, WindowExt as _, checkbox::Checkbox, h_flex,
13+ input::EditorState,
14+ menu::{DropdownMenu as _, PopupMenuItem},
15+ notification::Notification,
16+ tooltip::Tooltip,
17+ v_flex,
18+ };
19+ use gpui_kit::prelude::FluentBuilder as _;
20+ use gpui_kit::*;
21+
22+ use super::Workspace;
23+ use crate::git::{self, GitStatus, StatusFile};
24+ use crate::ui::icons::Lucide;
25+ use crate::ui::metal::{self, MetalButton, MetalStyled as _};
26+
27+ /// What a git action does.
28+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
29+ pub enum GitAction {
30+ Commit,
31+ CommitPush,
32+ CommitPushPr,
33+ Push,
34+ PushPr,
35+ CreatePr,
36+ Pull,
37+ }
38+
39+ impl GitAction {
40+ fn label(self) -> &'static str {
41+ match self {
42+ GitAction::Commit => "Commit",
43+ GitAction::CommitPush => "Commit & push",
44+ GitAction::CommitPushPr => "Commit, push & PR",
45+ GitAction::Push => "Push",
46+ GitAction::PushPr => "Push & create PR",
47+ GitAction::CreatePr => "Create PR",
48+ GitAction::Pull => "Pull",
49+ }
50+ }
51+
52+ fn icon(self) -> Icon {
53+ match self {
54+ GitAction::Commit => Icon::new(Lucide::GitCommitHorizontal),
55+ GitAction::Push | GitAction::CommitPush => Icon::new(Lucide::CloudUpload),
56+ GitAction::Pull => Icon::new(Lucide::CloudDownload),
57+ GitAction::CommitPushPr | GitAction::PushPr | GitAction::CreatePr => Icon::new(Lucide::GitPullRequest),
58+ }
59+ }
60+
61+ fn commits(self) -> bool {
62+ matches!(self, GitAction::Commit | GitAction::CommitPush | GitAction::CommitPushPr)
63+ }
64+
65+ fn pushes(self) -> bool {
66+ matches!(self, GitAction::CommitPush | GitAction::CommitPushPr | GitAction::Push | GitAction::PushPr)
67+ }
68+
69+ fn opens_pr(self) -> bool {
70+ matches!(self, GitAction::CommitPushPr | GitAction::PushPr | GitAction::CreatePr)
71+ }
72+ }
73+
74+ /// The quick action: something to run, or a label with why there's
75+ /// nothing to run.
76+ #[derive(Clone, Debug, PartialEq)]
77+ pub enum Quick {
78+ Run(GitAction),
79+ Hint { label: &'static str, hint: &'static str },
80+ }
81+
82+ /// T3 Code's choice of quick action for how the branch stands.
83+ pub fn quick_action(status: &GitStatus, pr_open: bool) -> Quick {
84+ let hint = |label, hint| Quick::Hint { label, hint };
85+ if status.detached() {
86+ return hint("Commit", "Create and checkout a ref before pushing or opening a PR.");
87+ }
88+ let pr_or_default = pr_open || status.on_default();
89+ if status.has_changes() {
90+ return Quick::Run(match () {
91+ _ if status.upstream.is_none() && !status.has_origin => GitAction::Commit,
92+ _ if pr_or_default => GitAction::CommitPush,
93+ _ => GitAction::CommitPushPr,
94+ });
95+ }
96+ if status.upstream.is_none() {
97+ if !status.has_origin {
98+ return hint("Push", "Add an \"origin\" remote before pushing.");
99+ }
100+ if status.ahead_of_default == 0 && pr_open {
101+ return hint("Commit", "Branch is up to date. No action needed.");
102+ }
103+ if status.ahead_of_default == 0 {
104+ return hint("Push", "No local commits to push.");
105+ }
106+ return Quick::Run(if pr_or_default { GitAction::Push } else { GitAction::PushPr });
107+ }
108+ if status.ahead > 0 && status.behind > 0 {
109+ return hint("Sync ref", "Branch has diverged from upstream. Rebase/merge first.");
110+ }
111+ if status.behind > 0 {
112+ return Quick::Run(GitAction::Pull);
113+ }
114+ if status.ahead > 0 {
115+ return Quick::Run(if pr_or_default { GitAction::Push } else { GitAction::PushPr });
116+ }
117+ if pr_open {
118+ return hint("Commit", "Branch is up to date. No action needed.");
119+ }
120+ if status.ahead_of_default > 0 && !status.on_default() {
121+ return Quick::Run(GitAction::CreatePr);
122+ }
123+ hint("Commit", "Branch is up to date. No action needed.")
124+ }
125+
126+ /// Why a menu action can't run now, as T3 Code says it.
127+ fn disabled_reason(action: GitAction, status: &GitStatus, pr_open: bool) -> Option<&'static str> {
128+ match action {
129+ GitAction::Commit if !status.has_changes() => Some("Worktree is clean. Make changes before committing."),
130+ GitAction::Push if status.detached() => Some("Detached HEAD: check out a branch before pushing."),
131+ GitAction::Push if status.has_changes() => Some("Commit or stash local changes before pushing."),
132+ GitAction::Push if status.behind > 0 => Some("Branch is behind upstream. Pull/rebase before pushing."),
133+ GitAction::Push if !status.has_origin => Some("Add an \"origin\" remote before pushing."),
134+ GitAction::Push if status.upstream.is_some() && status.ahead == 0 || status.upstream.is_none() && status.ahead_of_default == 0 => {
135+ Some("No local commits to push.")
136+ }
137+ GitAction::CreatePr if status.detached() => Some("Detached HEAD: check out a branch before creating a PR."),
138+ GitAction::CreatePr if status.has_changes() => Some("Commit local changes before creating a PR."),
139+ GitAction::CreatePr if !status.has_origin => Some("Add an \"origin\" remote before creating a PR."),
140+ GitAction::CreatePr if status.ahead_of_default == 0 => Some("No local commits to include in a PR."),
141+ GitAction::CreatePr if status.behind > 0 => Some("Branch is behind upstream. Pull/rebase before creating a PR."),
142+ GitAction::CreatePr if pr_open => Some("A pull request is already open."),
143+ _ => None,
144+ }
145+ }
146+
147+ /// What a finished action reports.
148+ struct Outcome {
149+ title: String,
150+ url: Option<String>,
151+ }
152+
153+ /// Runs an action in `path`: commits (with `message`, or a generated one,
154+ /// on a new feature branch when asked), pushes and opens a pull request.
155+ /// Blocks; run it in the background.
156+ fn run(
157+ path: &Path,
158+ action: GitAction,
159+ files: Option<Vec<String>>,
160+ message: String,
161+ feature_branch: bool,
162+ ) -> Result<Outcome, String> {
163+ let mut title = String::new();
164+ if action == GitAction::Pull {
165+ let status = git::status(path).unwrap_or_default();
166+ let before = git::git(path, &["rev-parse", "HEAD"]).unwrap_or_default();
167+ git::git(path, &["pull", "--ff-only"])?;
168+ let after = git::git(path, &["rev-parse", "HEAD"]).unwrap_or_default();
169+ let branch = status.branch.unwrap_or_default();
170+ return Ok(Outcome {
171+ title: if before == after {
172+ "Already up to date".into()
173+ } else {
174+ format!("Pulled: updated {branch} from {}", status.upstream.unwrap_or_default())
175+ },
176+ url: None,
177+ });
178+ }
179+ if action.commits() {
180+ if feature_branch {
181+ let fragment = crate::worktree::sanitize_fragment(message.lines().next().unwrap_or("update"));
182+ let mut branch = format!("feature/{fragment}");
183+ for n in 2.. {
184+ if git::git(path, &["rev-parse", "--verify", "-q", &format!("refs/heads/{branch}")]).is_err() {
185+ break;
186+ }
187+ branch = format!("feature/{fragment}-{n}");
188+ }
189+ git::git(path, &["switch", "-c", &branch])?;
190+ }
191+ match &files {
192+ Some(files) => {
193+ let mut args = vec!["add", "-A", "--"];
194+ args.extend(files.iter().map(String::as_str));
195+ git::git(path, &args)?;
196+ }
197+ None => {
198+ git::git(path, &["add", "-A"])?;
199+ }
200+ }
201+ let message = if message.trim().is_empty() {
202+ let (summary, patch) = git::staged(path);
203+ let branch = git::status(path).and_then(|status| status.branch);
204+ crate::textgen::commit_message(branch.as_deref(), &summary, &patch).unwrap_or_else(|| "Update files".into())
205+ } else {
206+ message
207+ };
208+ git::git(path, &["commit", "-q", "-m", &message])?;
209+ let sha = git::git(path, &["rev-parse", "--short", "HEAD"])?.trim().to_string();
210+ title = format!("Committed {sha}");
211+ }
212+ if action.pushes() || action.opens_pr() {
213+ let status = git::status(path).unwrap_or_default();
214+ let branch = status.branch.clone().ok_or("Detached HEAD: check out a branch before pushing.")?;
215+ if action.pushes() || status.upstream.is_none() {
216+ if status.upstream.is_none() {
217+ git::git(path, &["push", "-q", "-u", "origin", &branch])?;
218+ } else {
219+ git::git(path, &["push", "-q"])?;
220+ }
221+ let sha = git::git(path, &["rev-parse", "--short", "HEAD"])?.trim().to_string();
222+ title = format!("Pushed {sha} to {branch}");
223+ }
224+ if action.opens_pr() {
225+ let gh = crate::ui::workspace::find_program(&["gh"]).ok_or("Install the GitHub CLI (gh) to create pull requests.")?;
226+ let base = status.default_branch.clone().unwrap_or_else(|| "main".into());
227+ let base_ref = format!("origin/{base}");
228+ let commits = git::git(path, &["log", "--oneline", &format!("{base_ref}..HEAD")]).unwrap_or_default();
229+ let stat = git::git(path, &["diff", "--stat", &format!("{base_ref}...HEAD")]).unwrap_or_default();
230+ let patch = git::git(path, &["diff", &format!("{base_ref}...HEAD")]).unwrap_or_default();
231+ let mut command = std::process::Command::new(gh);
232+ command.current_dir(path).args(["pr", "create", "--base", &base, "--head", &branch]);
233+ match crate::textgen::pull_request_content(&base, &branch, &commits, &stat, &patch) {
234+ Some((pr_title, body)) => command.args(["--title", &pr_title, "--body", &body]),
235+ None => command.arg("--fill"),
236+ };
237+ let output = command.stdin(std::process::Stdio::null()).output().map_err(|err| err.to_string())?;
238+ if !output.status.success() {
239+ return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
240+ }
241+ let url = String::from_utf8_lossy(&output.stdout).lines().rev().find(|line| line.starts_with("http")).map(str::to_string);
242+ let number = url.as_deref().and_then(|url| url.rsplit('/').next()).unwrap_or_default().to_string();
243+ return Ok(Outcome { title: format!("Created PR #{number}"), url });
244+ }
245+ }
246+ Ok(Outcome { title: if title.is_empty() { "Done".into() } else { title }, url: None })
247+ }
248+
249+ impl Workspace {
250+ /// The folder the git actions work in: the open thread's worktree, or
251+ /// its project's checkout. `None` for a thread on another device.
252+ fn git_path(&self, cx: &App) -> Option<PathBuf> {
253+ let project = self.active_project()?;
254+ if project.environment.is_some() {
255+ return None;
256+ }
257+ Some(self.active_worktree(cx).unwrap_or_else(|| project.path.clone()))
258+ }
259+
260+ /// Starts an action, after asking first when it would push to the
261+ /// default branch.
262+ pub(super) fn git_action(&mut self, action: GitAction, window: &mut Window, cx: &mut Context<Self>) {
263+ let Some((_, status)) = self.git_status.clone() else { return };
264+ if action == GitAction::Commit {
265+ self.open_commit_dialog(status, window, cx);
266+ return;
267+ }
268+ if (action.pushes() || action.opens_pr()) && status.on_default() {
269+ self.confirm_default_branch(action, &status, None, window, cx);
270+ return;
271+ }
272+ self.start_git_action(action, None, String::new(), false, window, cx);
273+ }
274+
275+ fn start_git_action(
276+ &mut self,
277+ action: GitAction,
278+ files: Option<Vec<String>>,
279+ message: String,
280+ feature_branch: bool,
281+ window: &mut Window,
282+ cx: &mut Context<Self>,
283+ ) {
284+ let Some(path) = self.git_path(cx) else { return };
285+ let status = match action {
286+ GitAction::Pull => "Pulling latest changes...",
287+ _ if action.commits() && message.trim().is_empty() => "Generating commit message...",
288+ _ if action.commits() => "Committing...",
289+ _ if action.pushes() => "Pushing...",
290+ _ => "Creating pull request...",
291+ };
292+ self.git_running = Some((status, Instant::now()));
293+ cx.notify();
294+ let task = cx.background_executor().spawn({
295+ let path = path.clone();
296+ async move { run(&path, action, files, message, feature_branch) }
297+ });
298+ cx.spawn_in(window, async move |this, cx| {
299+ let result = task.await;
300+ let _ = this.update_in(cx, |this, window, cx| {
301+ this.git_running = None;
302+ match result {
303+ Ok(outcome) => {
304+ let mut notification = Notification::success(outcome.title.clone());
305+ if let Some(url) = outcome.url {
306+ notification = notification.content(move |_, _, _| {
307+ let url = url.clone();
308+ MetalButton::new("view-pr")
309+ .compact()
310+ .label("View PR")
311+ .on_click(move |_, _, _| crate::ui::workspace::open_url(&url))
312+ .into_any_element()
313+ });
314+ }
315+ window.push_notification(notification, cx);
316+ }
317+ Err(err) => window.push_notification(Notification::error(err).title("Action failed"), cx),
318+ }
319+ this.poll_git(cx);
320+ cx.notify();
321+ });
322+ })
323+ .detach();
324+ }
325+
326+ /// T3 Code's question before pushing to the default branch: go on, or
327+ /// check out a feature branch and run the same action there.
328+ fn confirm_default_branch(
329+ &mut self,
330+ action: GitAction,
331+ status: &GitStatus,
332+ commit: Option<(Option<Vec<String>>, String)>,
333+ window: &mut Window,
334+ cx: &mut Context<Self>,
335+ ) {
336+ let branch = status.branch.clone().unwrap_or_default();
337+ let with_commit = action.commits() || commit.is_some();
338+ let (title, description, continue_label): (&str, String, String) = match (action.opens_pr(), with_commit) {
339+ (false, true) => (
340+ "Commit & push to default ref?",
341+ "This action will commit and push changes".into(),
342+ format!("Commit & push to {branch}"),
343+ ),
344+ (false, false) => ("Push to default ref?", "This action will push local commits".into(), format!("Push to {branch}")),
345+ (true, true) => (
346+ "Commit, push & create PR from default ref?",
347+ "This action will commit, push, and create a PR".into(),
348+ "Commit, push & create PR".into(),
349+ ),
350+ (true, false) => (
351+ "Push & create PR from default ref?",
352+ "This action will push local commits and create a PR".into(),
353+ "Push & create PR".into(),
354+ ),
355+ };
356+ let description =
357+ format!("{description} on \"{branch}\". You can continue on this ref or create a feature ref and run the same action there.");
358+ let workspace = cx.entity().downgrade();
359+ let (files, message) = commit.unwrap_or((None, String::new()));
360+ window.open_dialog(cx, move |dialog, _, cx| {
361+ let p = metal::palette(cx);
362+ let (stay, feature) = (workspace.clone(), workspace.clone());
363+ let (stay_files, feature_files) = (files.clone(), files.clone());
364+ let (stay_message, feature_message) = (message.clone(), message.clone());
365+ dialog
366+ .w(px(576.))
367+ .title(title)
368+ .child(div().text_size(px(13.)).text_color(p.ink2).child(description.clone()))
369+ .footer(
370+ h_flex()
371+ .w_full()
372+ .justify_end()
373+ .gap(px(8.))
374+ .child(MetalButton::new("default-abort").compact().label("Abort").on_click(|_, window, cx| window.close_dialog(cx)))
375+ .child(MetalButton::new("default-continue").compact().label(continue_label.clone()).on_click(
376+ move |_, window, cx| {
377+ window.close_dialog(cx);
378+ let (files, message) = (stay_files.clone(), stay_message.clone());
379+ let _ = stay.update(cx, |this, cx| this.start_git_action(action, files, message, false, window, cx));
380+ },
381+ ))
382+ .child(
383+ MetalButton::new("default-feature")
384+ .primary()
385+ .compact()
386+ .label("Check out feature branch & continue")
387+ .on_click(move |_, window, cx| {
388+ window.close_dialog(cx);
389+ let (files, message) = (feature_files.clone(), feature_message.clone());
390+ let _ = feature.update(cx, |this, cx| this.start_git_action(action, files, message, true, window, cx));
391+ }),
392+ ),
393+ )
394+ });
395+ }
396+
397+ /// T3 Code's commit dialog: the branch, the files (pick which with
398+ /// Edit), and an optional message.
399+ fn open_commit_dialog(&mut self, status: GitStatus, window: &mut Window, cx: &mut Context<Self>) {
400+ let workspace = cx.entity().downgrade();
401+ let dialog = cx.new(|cx| CommitDialog::new(status, workspace, window, cx));
402+ window.open_dialog(cx, move |modal, _, _| {
403+ let dialog = dialog.clone();
404+ modal.w(px(560.)).title("Commit changes").child(dialog)
405+ });
406+ }
407+
408+ /// The git actions' row in the thread details panel: the quick action
409+ /// and the menu beside it, or what's running and for how long.
410+ pub(super) fn render_git_actions(&self, cx: &mut Context<Self>) -> AnyElement {
411+ let p = metal::palette(cx);
412+ let Some((_, status)) = self.git_status.clone() else {
413+ return super::Workspace::panel_row(Lucide::GitCommitHorizontal, "Commit", p).text_color(p.ink3).into_any_element();
414+ };
415+ let pr_open = self.active.as_ref().and_then(|session| session.read(cx).pull_request.clone()).is_some_and(|pr| pr.is_open());
416+ if let Some((label, started)) = self.git_running {
417+ return h_flex()
418+ .h(px(32.))
419+ .px(px(10.))
420+ .gap(px(10.))
421+ .text_size(px(13.))
422+ .text_color(p.ink2)
423+ .child(metal::led("git-running", metal::Led::Live, true, p))
424+ .child(div().flex_1().min_w_0().truncate().child(label))
425+ .child(div().text_size(px(12.)).text_color(p.ink3).child(elapsed(started)))
426+ .into_any_element();
427+ }
428+ let quick = quick_action(&status, pr_open);
429+ let (icon, label, hint, action) = match &quick {
430+ Quick::Run(action) => (action.icon(), action.label(), None, Some(*action)),
431+ Quick::Hint { label, hint } => {
432+ let icon = match *label {
433+ "Commit" => Icon::new(Lucide::GitCommitHorizontal),
434+ "Push" => Icon::new(Lucide::CloudUpload),
435+ _ => Icon::new(IconName::Info),
436+ };
437+ (icon, *label, Some(*hint), None)
438+ }
439+ };
440+ let workspace = cx.entity().downgrade();
441+ let menu_status = status.clone();
442+ h_flex()
443+ .w_full()
444+ .rounded(px(10.))
445+ .hover(|style| style.bg(p.row_hover))
446+ .child(
447+ h_flex()
448+ .id("git-quick-action")
449+ .flex_1()
450+ .min_w_0()
451+ .h(px(32.))
452+ .px(px(10.))
453+ .gap(px(10.))
454+ .text_size(px(13.))
455+ .font_weight(FontWeight::MEDIUM)
456+ .text_color(if action.is_some() { p.ink.opacity(0.8) } else { p.ink3 })
457+ .cursor_pointer()
458+ .child(icon.size(px(16.)).text_color(p.ink3))
459+ .child(div().truncate().child(label))
460+ .when_some(hint, |this, hint| {
461+ this.tooltip(move |window, cx| Tooltip::new(hint).build(window, cx))
462+ .on_click(move |_, window, cx| window.push_notification(Notification::info(hint).title(label), cx))
463+ })
464+ .when_some(action, |this, action| {
465+ this.on_click(cx.listener(move |this, _, window, cx| this.git_action(action, window, cx)))
466+ }),
467+ )
468+ .child(div().w(px(1.)).h(px(16.)).bg(p.rule))
469+ .child(
470+ MetalButton::new("git-action-options")
471+ .ghost()
472+ .mini()
473+ .w(px(32.))
474+ .h(px(32.))
475+ .icon(IconName::ChevronDown)
476+ .tooltip("Git action options")
477+ .dropdown_menu_with_anchor(Anchor::TopRight, move |mut menu, _, _| {
478+ for action in [GitAction::Commit, GitAction::Push, GitAction::CreatePr] {
479+ if action != GitAction::Commit && !menu_status.has_origin {
480+ continue;
481+ }
482+ if action == GitAction::CreatePr && pr_open {
483+ continue;
484+ }
485+ let reason = disabled_reason(action, &menu_status, pr_open);
486+ let workspace = workspace.clone();
487+ menu = menu.item(
488+ PopupMenuItem::new(action.label())
489+ .icon(action.icon())
490+ .disabled(reason.is_some())
491+ .on_click(move |_, window, cx| {
492+ let _ = workspace.update(cx, |this, cx| this.git_action(action, window, cx));
493+ }),
494+ );
495+ }
496+ if menu_status.detached() {
497+ menu = menu.separator().label("Detached HEAD: create and check out a branch to enable push and pull request actions.");
498+ } else if menu_status.behind > 0 {
499+ menu = menu.separator().label("Behind upstream. Pull/rebase first.");
500+ }
501+ menu
502+ }),
503+ )
504+ .into_any_element()
505+ }
506+ }
507+
508+ fn elapsed(started: Instant) -> String {
509+ let secs = started.elapsed().as_secs();
510+ if secs < 60 { format!("{secs}s") } else { format!("{}m {}s", secs / 60, secs % 60) }
511+ }
512+
513+ /// The commit dialog's state.
514+ struct CommitDialog {
515+ status: GitStatus,
516+ selected: Vec<bool>,
517+ editing: bool,
518+ message: Entity<EditorState>,
519+ workspace: WeakEntity<Workspace>,
520+ }
521+
522+ impl CommitDialog {
523+ fn new(status: GitStatus, workspace: WeakEntity<Workspace>, window: &mut Window, cx: &mut Context<Self>) -> Self {
524+ let message = cx.new(|cx| {
525+ crate::ui::markdown_input::composer_state(EditorState::new(window, cx), false).placeholder("Leave empty to auto-generate")
526+ });
527+ let selected = vec![true; status.files.len()];
528+ Self { status, selected, editing: false, message, workspace }
529+ }
530+
531+ fn picked(&self) -> Vec<&StatusFile> {
532+ self.status.files.iter().zip(&self.selected).filter(|(_, on)| **on).map(|(file, _)| file).collect()
533+ }
534+
535+ fn commit(&mut self, feature_branch: bool, window: &mut Window, cx: &mut Context<Self>) {
536+ let all = self.selected.iter().all(|on| *on);
537+ let files = (!all).then(|| self.picked().into_iter().map(|file| file.path.clone()).collect::<Vec<_>>());
538+ let message = self.message.read(cx).value().to_string();
539+ window.close_dialog(cx);
540+ let _ = self.workspace.update(cx, |this, cx| {
541+ this.start_git_action(GitAction::Commit, files, message, feature_branch, window, cx)
542+ });
543+ }
544+ }
545+
546+ impl Render for CommitDialog {
547+ fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
548+ let p = metal::palette(cx);
549+ let picked = self.picked();
550+ let (added, removed) = picked.iter().fold((0, 0), |(a, r), file| (a + file.added.unwrap_or(0), r + file.removed.unwrap_or(0)));
551+ let count = picked.len();
552+ let total = self.status.files.len();
553+ let on_default = self.status.on_default();
554+ let all = self.selected.iter().all(|on| *on);
555+ v_flex()
556+ .gap(px(12.))
557+ .child(
558+ div()
559+ .text_size(px(13.))
560+ .text_color(p.ink2)
561+ .child("Review and confirm your commit. Leave the message blank to auto-generate one."),
562+ )
563+ .child(
564+ v_flex()
565+ .p(px(12.))
566+ .gap(px(8.))
567+ .rounded(px(14.))
568+ .well(p)
569+ .text_size(px(13.))
570+ .child(
571+ h_flex()
572+ .gap(px(8.))
573+ .child(div().w(px(48.)).text_color(p.ink3).child("Branch"))
574+ .child(div().flex_1().truncate().text_color(p.ink).child(self.status.branch.clone().unwrap_or_else(|| "(detached HEAD)".into())))
575+ .when(on_default, |this| this.child(div().text_size(px(12.)).text_color(p.amber_ink).child("Default branch"))),
576+ )
577+ .child(
578+ h_flex()
579+ .gap(px(8.))
580+ .child(div().w(px(48.)).text_color(p.ink3).child("Files"))
581+ .child(div().flex_1().text_color(p.ink2).when(count < total, |this| this.child(format!("({count} of {total})"))))
582+ .when(self.editing, |this| {
583+ this.child(
584+ Checkbox::new("commit-all")
585+ .checked(all)
586+ .on_click(cx.listener(move |this, _, _, cx| {
587+ let on = !all;
588+ this.selected.iter_mut().for_each(|selected| *selected = on);
589+ cx.notify();
590+ })),
591+ )
592+ })
593+ .child(
594+ MetalButton::new("commit-edit-files")
595+ .ghost()
596+ .mini()
597+ .label(if self.editing { "Done" } else { "Edit" })
598+ .on_click(cx.listener(|this, _, _, cx| {
599+ this.editing = !this.editing;
600+ cx.notify();
601+ })),
602+ ),
603+ )
604+ .child(
605+ v_flex()
606+ .id("commit-files")
607+ .max_h(px(176.))
608+ .overflow_y_scroll()
609+ .font_family(metal::MONO)
610+ .text_size(px(12.))
611+ .when(total == 0, |this| this.child(div().text_color(p.ink3).child("none")))
612+ .children(self.status.files.iter().enumerate().map(|(ix, file)| {
613+ let on = self.selected[ix];
614+ h_flex()
615+ .gap(px(8.))
616+ .py(px(2.))
617+ .when(self.editing, |this| {
618+ this.child(Checkbox::new(("commit-file", ix)).checked(on).on_click(cx.listener(move |this, _, _, cx| {
619+ this.selected[ix] = !this.selected[ix];
620+ cx.notify();
621+ })))
622+ })
623+ .child(div().flex_1().min_w_0().truncate().text_color(if on { p.ink } else { p.ink3 }).child(file.path.clone()))
624+ .child(if on {
625+ h_flex()
626+ .gap(px(4.))
627+ .child(div().text_color(p.green_ink).child(format!("+{}", file.added.unwrap_or(0))))
628+ .child(div().text_color(p.red).child(format!("-{}", file.removed.unwrap_or(0))))
629+ .into_any_element()
630+ } else {
631+ div().text_color(p.ink3).child("Excluded").into_any_element()
632+ })
633+ })),
634+ )
635+ .child(
636+ h_flex()
637+ .justify_end()
638+ .gap(px(4.))
639+ .font_family(metal::MONO)
640+ .text_size(px(12.))
641+ .child(div().text_color(p.green_ink).child(format!("+{added}")))
642+ .child(div().text_color(p.ink3).child("/"))
643+ .child(div().text_color(p.red).child(format!("-{removed}"))),
644+ ),
645+ )
646+ .child(
647+ v_flex()
648+ .gap(px(6.))
649+ .child(div().text_size(px(12.)).font_weight(FontWeight::MEDIUM).text_color(p.ink2).child("Commit message (optional)"))
650+ .child(
651+ div()
652+ .px(px(10.))
653+ .py(px(6.))
654+ .rounded(px(10.))
655+ .well(p)
656+ .text_size(px(13.))
657+ .child(crate::ui::markdown_input::MarkdownInput::new(&self.message).rows(3, 8)),
658+ ),
659+ )
660+ .child(
661+ h_flex()
662+ .justify_end()
663+ .gap(px(8.))
664+ .child(MetalButton::new("commit-cancel").compact().label("Cancel").on_click(|_, window, cx| window.close_dialog(cx)))
665+ .child(
666+ MetalButton::new("commit-new-branch")
667+ .compact()
668+ .label("Commit on new branch")
669+ .disabled(count == 0)
670+ .on_click(cx.listener(|this, _, window, cx| this.commit(true, window, cx))),
671+ )
672+ .child(
673+ MetalButton::new("commit-confirm")
674+ .primary()
675+ .compact()
676+ .label("Commit")
677+ .disabled(count == 0)
678+ .on_click(cx.listener(|this, _, window, cx| this.commit(false, window, cx))),
679+ ),
680+ )
681+ }
682+ }