facett-git 0.1.10

facett — git repository facet: browse branches, commit log, the file tree, and syntax-coloured source, all clickable. gix-backed (optional feature), egui-rendered. A consumer (nornir) drops it in to show git for any repo.
Documentation
//! **`facett-git`** — a facett [`Facet`](facett_core::Facet) that shows a git
//! repository: its **branches**, the **commit log**, the **file tree**, and
//! **syntax-coloured source** — all clickable. A consumer (e.g. nornir's viz) drops
//! it into a `FacetDeck` to surface git for any repo.
//!
//! - The repository data is a pure [`RepoSnapshot`] (branches / commits / tree). With
//!   the **`gix` feature** it is read live from disk via
//!   [`RepoSnapshot::open`](model::RepoSnapshot::open); without it the host supplies
//!   a snapshot (and a blob), so the facet builds and tests with no git dependency.
//! - Source is rendered with a deterministic Rust [`highlight`]er into an egui
//!   `LayoutJob` (egui *can* render coloured Rust text — this is how).
//! - Everything observable (selected branch, open path, counts) is in
//!   [`state_json`](GitView::state_json) (LAW 6 / FC-6), so it is headless-testable.

pub mod highlight;
pub mod model;

pub use highlight::{highlight, Kind, Span};
pub use model::{Blob, Branch, CommitRow, RepoSnapshot, TreeEntry};

use egui::{text::LayoutJob, Color32, FontId, TextFormat, Ui};
use facett_core::clip::{ClipKind, ClipPayload, CopySource};
use facett_core::Facet;

/// Which middle-pane view is showing.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Pane {
    Tree,
    Log,
}

/// The git repository facet.
pub struct GitView {
    title: String,
    snap: RepoSnapshot,
    /// The open source blob (set by the host, or by `gix` when a file is clicked).
    blob: Option<Blob>,
    selected_branch: Option<String>,
    selected_path: Option<String>,
    pane: Pane,
    scale: f32,
}

impl GitView {
    /// A facet over an in-memory [`RepoSnapshot`] (host-supplied data path).
    #[must_use]
    pub fn new(snap: RepoSnapshot) -> Self {
        let selected_branch = snap.head_branch().map(str::to_string);
        Self {
            title: "Git".to_string(),
            snap,
            blob: None,
            selected_branch,
            selected_path: None,
            pane: Pane::Tree,
            scale: 1.0,
        }
    }

    /// A demo facet (no git needed) — handy for examples/snapshot tests.
    #[must_use]
    pub fn demo() -> Self {
        Self::new(RepoSnapshot::demo())
    }

    /// Open a repo live (requires the `gix` feature). `max_commits` caps the log.
    #[cfg(feature = "gix")]
    pub fn open(path: &str, max_commits: usize) -> Result<Self, String> {
        Ok(Self::new(RepoSnapshot::open(path, max_commits)?))
    }

    /// Replace the open source blob (the source pane content).
    pub fn set_blob(&mut self, blob: Blob) {
        self.selected_path = Some(blob.path.clone());
        self.blob = Some(blob);
    }

    /// The current snapshot (read-only).
    #[must_use]
    pub fn snapshot(&self) -> &RepoSnapshot {
        &self.snap
    }

    /// The currently selected branch name.
    #[must_use]
    pub fn selected_branch(&self) -> Option<&str> {
        self.selected_branch.as_deref()
    }

    /// Build a syntax-coloured `LayoutJob` for Rust `src` under `theme`. Public so a
    /// host can render code with the same colours outside the facet.
    #[must_use]
    pub fn highlight_job(src: &str, theme: &Theme, font: FontId) -> LayoutJob {
        let mut job = LayoutJob::default();
        for span in highlight(src) {
            let fmt = TextFormat { font_id: font.clone(), color: theme.color(span.kind), ..Default::default() };
            job.append(&src[span.start..span.end], 0.0, fmt);
        }
        job
    }
}

/// Colours for each syntax [`Kind`]. [`Default`] is a dark theme.
#[derive(Debug, Clone, Copy)]
pub struct Theme {
    pub text: Color32,
    pub keyword: Color32,
    pub ty: Color32,
    pub string: Color32,
    pub number: Color32,
    pub comment: Color32,
    pub attribute: Color32,
    pub punct: Color32,
}

impl Default for Theme {
    fn default() -> Self {
        Self {
            text: Color32::from_rgb(0xD4, 0xD4, 0xD4),
            keyword: Color32::from_rgb(0x56, 0x9C, 0xD6),
            ty: Color32::from_rgb(0x4E, 0xC9, 0xB0),
            string: Color32::from_rgb(0xCE, 0x91, 0x78),
            number: Color32::from_rgb(0xB5, 0xCE, 0xA8),
            comment: Color32::from_rgb(0x6A, 0x99, 0x55),
            attribute: Color32::from_rgb(0xDC, 0xDC, 0xAA),
            punct: Color32::from_rgb(0xD4, 0xD4, 0xD4),
        }
    }
}

impl Theme {
    /// The colour for a syntax kind.
    #[must_use]
    pub fn color(&self, kind: Kind) -> Color32 {
        match kind {
            Kind::Text => self.text,
            Kind::Keyword => self.keyword,
            Kind::Type => self.ty,
            Kind::Str => self.string,
            Kind::Number => self.number,
            Kind::Comment => self.comment,
            Kind::Attribute => self.attribute,
            Kind::Punct => self.punct,
        }
    }
}

impl GitView {
    /// The copyable text: the selected file path if one is open, else the selected
    /// branch name + tip, else the commit log as a TSV rectangle
    /// (`id \t summary \t author \t time`).
    pub fn copy_text(&self) -> Option<String> {
        if let Some(path) = self.selected_path.as_deref() {
            return Some(path.to_string());
        }
        if let Some(b) = self.selected_branch.as_deref().and_then(|n| self.snap.branches.iter().find(|b| b.name == n)) {
            return Some(format!("{} ({})", b.name, b.tip));
        }
        if self.snap.commits.is_empty() {
            return None;
        }
        let mut out = String::from("id\tsummary\tauthor\ttime");
        for c in &self.snap.commits {
            out.push('\n');
            out.push_str(&format!("{}\t{}\t{}\t{}", c.id, c.summary, c.author, c.time));
        }
        Some(out)
    }
}

// ── typed copy (§16) — read-only git browser: path / branch / commit-log rows ──
impl CopySource for GitView {
    fn copy_kinds(&self) -> &[ClipKind] {
        &[ClipKind::Text]
    }

    fn copy_payload(&self) -> Option<ClipPayload> {
        self.copy_text().map(ClipPayload::Text)
    }
}

impl Facet for GitView {
    fn title(&self) -> &str {
        &self.title
    }

    fn copy(&mut self) -> Option<String> {
        self.copy_payload().map(|p| p.as_text())
    }

    fn ui(&mut self, ui: &mut Ui) {
        let theme = Theme::default();
        ui.horizontal(|ui| {
            // ── branches column ──
            ui.vertical(|ui| {
                ui.strong("Branches");
                for b in &self.snap.branches {
                    let label = if b.is_head { format!("{} ({})", b.name, b.tip) } else { format!("  {} ({})", b.name, b.tip) };
                    let sel = self.selected_branch.as_deref() == Some(b.name.as_str());
                    if ui.selectable_label(sel, label).clicked() {
                        self.selected_branch = Some(b.name.clone());
                    }
                }
            });
            ui.separator();
            // ── tree / log column ──
            ui.vertical(|ui| {
                ui.horizontal(|ui| {
                    if ui.selectable_label(self.pane == Pane::Tree, "Tree").clicked() {
                        self.pane = Pane::Tree;
                    }
                    if ui.selectable_label(self.pane == Pane::Log, "Log").clicked() {
                        self.pane = Pane::Log;
                    }
                });
                match self.pane {
                    Pane::Tree => {
                        for e in &self.snap.tree {
                            let icon = if e.is_dir { "📁" } else { "📄" };
                            let sel = self.selected_path.as_deref() == Some(e.name.as_str());
                            if ui.selectable_label(sel, format!("{icon} {}", e.name)).clicked() {
                                self.selected_path = Some(e.name.clone());
                            }
                        }
                    }
                    Pane::Log => {
                        for c in &self.snap.commits {
                            ui.label(format!("{} {}{}", c.id, c.summary, c.author));
                        }
                    }
                }
            });
            ui.separator();
            // ── source column ──
            ui.vertical(|ui| {
                ui.strong(self.selected_path.as_deref().unwrap_or("(no file)"));
                if let Some(blob) = &self.blob {
                    if blob.binary {
                        ui.weak("binary file");
                    } else {
                        let job = GitView::highlight_job(&blob.text, &theme, FontId::monospace(12.0 * self.scale));
                        ui.label(job);
                    }
                }
            });
        });
    }

    fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "title": self.title,
            "workdir": self.snap.workdir,
            "branches": self.snap.branches.len(),
            "head": self.snap.head_branch(),
            "commits": self.snap.commits.len(),
            "tree_entries": self.snap.tree.len(),
            "pane": match self.pane { Pane::Tree => "tree", Pane::Log => "log" },
            "selected_branch": self.selected_branch,
            "selected_path": self.selected_path,
            "open_blob": self.blob.as_ref().map(|b| &b.path),
            "scale": self.scale,
        })
    }

    fn caps(&self) -> facett_core::FacetCaps {
        facett_core::FacetCaps::NONE.scalable().selectable().themeable().copyable()
    }

    fn scale(&self) -> f32 {
        self.scale
    }
    fn set_scale(&mut self, scale: f32) {
        self.scale = scale.clamp(0.5, 4.0);
    }

    fn selection_json(&self) -> serde_json::Value {
        serde_json::json!({ "branch": self.selected_branch, "path": self.selected_path })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn typed_copy_prefers_path_then_branch_then_commit_log() {
        use facett_core::clip::{ClipKind, CopySource};
        let mut v = GitView::demo();
        // Head branch is pre-selected -> copy is the branch name + tip.
        let p = v.copy_payload().expect("a demo repo copies");
        assert_eq!(p.kind(), ClipKind::Text);
        let head = v.selected_branch().unwrap().to_string();
        assert!(p.as_text().starts_with(&head), "branch copy: {}", p.as_text());
        // Open a blob -> copy prefers the selected file path.
        v.set_blob(Blob { path: "src/main.rs".into(), text: "fn main() {}".into(), binary: false });
        assert_eq!(v.copy_payload().unwrap().as_text(), "src/main.rs");
    }

    /// INJECT-ASSERT: state_json exposes every visible count + the selection (LAW 6
    /// headless surface), and the head branch is pre-selected.
    #[test]
    fn state_json_exposes_counts_and_selection() {
        let v = GitView::demo();
        let s = v.state_json();
        assert_eq!(s["branches"], 2);
        assert_eq!(s["commits"], 2);
        assert_eq!(s["tree_entries"], 3);
        assert_eq!(s["head"], "main");
        assert_eq!(s["selected_branch"], "main");
        assert_eq!(s["pane"], "tree");
    }

    /// INJECT-ASSERT: setting a blob updates the open path + state, and scale clamps.
    #[test]
    fn set_blob_and_scale_clamp() {
        let mut v = GitView::demo();
        v.set_blob(Blob { path: "src/lib.rs".into(), text: "fn x() {}".into(), binary: false });
        assert_eq!(v.state_json()["open_blob"], "src/lib.rs");
        assert_eq!(v.selection_json()["path"], "src/lib.rs");
        v.set_scale(99.0);
        assert_eq!(v.scale(), 4.0, "scale clamps to the cap");
        v.set_scale(0.01);
        assert_eq!(v.scale(), 0.5);
    }

    /// INJECT-ASSERT: the highlight job carries one coloured section per span and
    /// the keyword colour differs from plain text (proves colouring is applied).
    #[test]
    fn highlight_job_colours_spans() {
        let theme = Theme::default();
        let job = GitView::highlight_job("fn main() {}", &theme, FontId::monospace(12.0));
        assert!(!job.sections.is_empty());
        // The 'fn' keyword section is the keyword colour, not the default text colour.
        let kw = job.sections.iter().find(|s| job.text[s.byte_range.clone()].starts_with("fn")).unwrap();
        assert_eq!(kw.format.color, theme.keyword);
        assert_ne!(theme.keyword, theme.text);
    }

    /// INJECT-ASSERT: caps advertise scalable + selectable + themeable.
    #[test]
    fn caps_advertised() {
        let c = GitView::demo().caps();
        assert!(c.scalable && c.selectable && c.themeable);
    }
}