Vectrum
Sign In
174 lines · 7.6 KB · Rust
//! 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, text_field};
use crate::settings::{self, BranchNaming};
use crate::ui::icons::Lucide;
use crate::ui::metal::{self, Led, MetalButton, MetalStyled as _};
use crate::ui::workspace;

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 => {}
        }
        vec![version_control, providers, naming.render(cx)]
    }
}