//! The Source Control page, after T3 Code's: the version control and
//! hosting tools found on this machine, then how worktree branches are
//! named.
use std::process::{Command, Stdio};
use gpui_kit::component::{Icon, input::Textarea};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use super::SettingsView;
use super::widgets::{Section, SettingRow, choice, code, empty_row, reset, switch, text_field};
use crate::settings::{self, BranchNaming, WritingStyle};
use crate::ui::icons::Lucide;
use crate::ui::metal::{self, Led, MetalButton, MetalStyled as _};
use crate::ui::workspace;
const WRITING_STYLES: &[(WritingStyle, &str)] = &[
(WritingStyle::RepoConventions, "Repository conventions"),
(WritingStyle::Conventional, "Conventional Commits"),
(WritingStyle::Custom, "Custom instructions"),
];
const BRANCH_NAMING: &[(BranchNaming, &str)] = &[
(BranchNaming::Static, "Static prefix"),
(BranchNaming::Semantic, "Semantic prefix"),
(BranchNaming::Custom, "Custom instructions"),
];
/// One tool the page found, or didn't.
#[derive(Clone, Debug)]
pub(super) struct Tool {
pub version: Option<String>,
/// "Available", "Authenticated as octocat", or why it isn't.
pub summary: String,
pub ok: bool,
}
/// What the last scan found.
#[derive(Default)]
pub(super) struct SourceControlState {
scanning: bool,
git: Option<Tool>,
github: Option<Tool>,
}
/// Runs a program and returns its trimmed output, if it succeeded.
fn output(program: &std::path::Path, args: &[&str]) -> Option<String> {
let output = Command::new(program).args(args).stdin(Stdio::null()).output().ok()?;
output.status.success().then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn scan_git() -> Tool {
let Some(git) = workspace::find_program(&["git"]) else {
return Tool { version: None, summary: "Not available on this machine.".into(), ok: false };
};
let version = output(&git, &["--version"]).map(|text| text.trim_start_matches("git version ").to_string());
Tool { version, summary: "Available".into(), ok: true }
}
fn scan_github() -> Tool {
let Some(gh) = workspace::find_program(&["gh"]) else {
return Tool {
version: None,
summary: "Install the GitHub CLI (gh) to check pull requests for auto-settle.".into(),
ok: false,
};
};
let version = output(&gh, &["--version"])
.and_then(|text| text.lines().next().map(str::to_string))
.and_then(|line| line.split_whitespace().nth(2).map(str::to_string));
match output(&gh, &["api", "user", "--jq", ".login"]) {
Some(login) if !login.is_empty() => Tool { version, summary: format!("Authenticated as {login}"), ok: true },
_ => Tool { version, summary: "Not authenticated. Run gh auth login.".into(), ok: false },
}
}
impl SettingsView {
/// Looks for git and the GitHub CLI in the background.
pub(super) fn scan_source_control(&mut self, cx: &mut Context<Self>) {
if self.source_control.scanning {
return;
}
self.source_control.scanning = true;
let task = cx.background_executor().spawn(async { (scan_git(), scan_github()) });
cx.spawn(async move |this, cx| {
let (git, github) = task.await;
let _ = this.update(cx, |this, cx| {
this.source_control = SourceControlState { scanning: false, git: Some(git), github: Some(github) };
cx.notify();
});
})
.detach();
cx.notify();
}
pub(super) fn render_source_control(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Vec<AnyElement> {
let p = metal::palette(cx);
let saved = settings::load();
let view = cx.weak_entity();
if self.source_control.git.is_none() && !self.source_control.scanning {
self.scan_source_control(cx);
}
let scanning = self.source_control.scanning;
let tool_row = |name: &'static str, icon: Lucide, tool: Option<&Tool>| -> AnyElement {
let Some(tool) = tool else {
return empty_row(format!("Looking for {name}…"), p);
};
SettingRow::new(name)
.icon(Icon::new(icon).size(px(14.)).text_color(p.ink2))
.when_some(tool.version.clone(), |row, version| row.title_suffix(code(version, p)))
.description(tool.summary.clone())
.control(metal::led(SharedString::from(format!("tool-{name}")), if tool.ok { Led::Live } else { Led::Off }, false, p))
.into_any_element()
};
let rescan = MetalButton::new("rescan-source-control")
.ghost()
.mini()
.icon(gpui_kit::component::IconName::RefreshCw)
.tooltip("Rescan Git and hosting integrations")
.disabled(scanning)
.on_click(cx.listener(|this, _, _, cx| this.scan_source_control(cx)));
let version_control = Section::new("Version Control")
.id("source-control")
.header_action(rescan)
.row(tool_row("Git", Lucide::GitBranch, self.source_control.git.as_ref()))
.render(cx);
let providers = Section::new("Source Control Providers")
.row(tool_row("GitHub", Lucide::GitPullRequest, self.source_control.github.as_ref()))
.render(cx);
let mut naming = Section::new("Text generation").id("source-control-text-generation").row(
SettingRow::new("Worktree branch naming")
.id("worktree-branch-naming")
.description("Choose how new worktree branches are named from your first message.")
.when(saved.branch_naming == BranchNaming::Semantic, |row| {
row.status(
"The model chooses a prefix that describes the work, such as feat/add-search, fix/login-timeout, or refactor/auth.",
)
})
.reset(reset(&view, |s| s.branch_naming, |s, naming| s.branch_naming = naming))
.control(choice("branch-naming", saved.branch_naming, BRANCH_NAMING, &view, |settings, naming| {
settings.branch_naming = naming
})),
);
match saved.branch_naming {
BranchNaming::Static => {
naming = naming.row(
SettingRow::new("Branch prefix")
.id("branch-prefix")
.description("For example, vectrum or vectrum/ produces vectrum/add-search. Leave empty for no prefix.")
.reset(reset(&view, |s| s.branch_prefix.clone(), |s, prefix| s.branch_prefix = prefix))
.control(text_field(&self.branch_prefix, 224., window, cx)),
);
}
BranchNaming::Custom => {
naming = naming.row(
SettingRow::new("Branch naming instructions")
.id("branch-naming-instructions")
.description(
"Appended to the naming prompt. The model returns the complete branch name; no prefix or suffix is added.",
)
.reset(reset(&view, |s| s.branch_instructions.clone(), |s, text| s.branch_instructions = text))
.below(
div()
.px(px(2.))
.py(px(2.))
.rounded(px(12.))
.well(p)
.text_size(px(12.5))
.text_color(p.ink)
.child(Textarea::new(&self.branch_instructions).appearance(false)),
),
);
}
BranchNaming::Semantic => {}
}
naming = naming.row(
SettingRow::new("Source control writing style")
.id("source-control-writing-style")
.description(match saved.writing_style {
WritingStyle::RepoConventions => {
"In each project, matches recent change descriptions and change request titles."
}
WritingStyle::Conventional => "Use Conventional Commit prefixes and keep change request text concise.",
WritingStyle::Custom => "Use your instructions for change descriptions and change requests in every project.",
})
.reset(reset(&view, |s| s.writing_style, |s, style| s.writing_style = style))
.control(choice("writing-style", saved.writing_style, WRITING_STYLES, &view, |settings, style| {
settings.writing_style = style
}))
.when(saved.writing_style == WritingStyle::Custom, |row| {
row.below(
div()
.px(px(2.))
.py(px(2.))
.rounded(px(12.))
.well(p)
.text_size(px(12.5))
.text_color(p.ink)
.child(Textarea::new(&self.writing_instructions).appearance(false)),
)
}),
);
naming = naming.row(
SettingRow::new("Follow change request templates")
.id("follow-change-request-templates")
.description("Use the repository's template for change request descriptions when available.")
.reset(reset(&view, |s| s.follow_pr_templates, |s, on| s.follow_pr_templates = on))
.control(switch("follow-pr-templates", saved.follow_pr_templates, &view, p, |settings, on| {
settings.follow_pr_templates = on
})),
);
vec![version_control, providers, naming.render(cx)]
}
}