Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

facett-git/src/lib.rs

//! **`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.
//!
//! ## FC contract (the canonical Elm split, FC-2 / FC-9)
//! - **[`GitState`]** — the complete observable, serializable, round-trippable state
//!   (the repo snapshot, the open blob, the selected branch / path, the visible pane,
//!   the zoom). [`GitView::state`] hands back a `&GitState`.
//! - **[`Msg`] + [`GitView::update`]** — the single mutation path (FC-2): the same
//!   gestures the canvas clicks drive (pick a branch, open a tree path, switch pane,
//!   zoom, load a blob), so a headless driver ([`facett_core::harness`]) reaches them.
//! - **[`GitView::view`]** — a **pure** paint (FC-9): it reads `&self`, paints the
//!   three columns, and *returns* the [`Msg`]s the interactions produced; the
//!   [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm) bridge applies them.
//! - **Rich `state_json`** — the derived counts (`branches` / `commits` /
//!   `tree_entries`), the head branch, and the flattened selection a driver reads are
//!   *derived* from the snapshot, not a plain `serde(state())`, so the macro is
//!   invoked in **form 3** (`custom_state_json`) to publish those keys.

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 serde::{Deserialize, Serialize};

/// Which middle-pane view is showing. Part of the observable [`GitState`], so it is
/// serializable; the string form the deck reads (`"tree"` / `"log"`) is emitted by
/// [`GitView::state_json`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Pane {
    Tree,
    Log,
}

/// A robot-/CLI-addressable control message (FC-2) — the named boundary a headless
/// driver (or a host) drives the git browser through, the same effect the canvas
/// clicks produce. Applied by [`GitView::update`]; it is the **only** mutation path.
/// Serde-tagged (`snake_case`) so the robot `update_json` seam parses one message
/// straight off the wire (every payload — `Pane` / `Blob` — is already serde-able).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Msg {
    /// Select a branch by its **name** (FC-5: a stable id, never a row index) — the
    /// branch-list click.
    SelectBranch(String),
    /// Open a tree path by its **name** (FC-5: a stable id) — the tree-entry click.
    SelectPath(String),
    /// Switch the middle pane between the tree and the commit log — the tab click.
    ShowPane(Pane),
    /// Set the source zoom (clamped to `0.5..=4.0`) — the host `set_scale`.
    SetScale(f32),
    /// Replace the open source blob (and select its path) — the host `set_blob` /
    /// the `gix` file-open path.
    SetBlob(Blob),
}

/// Side work as data (FC-8). The git browser does no I/O — every [`Msg`] mutates
/// only the in-memory [`GitState`] — so this is uninhabited on purpose: the
/// type-checked statement that [`GitView::update`] never asks the host to do
/// anything. (Live repo reads happen up-front in [`RepoSnapshot::open`], not here.)
#[derive(Clone, Debug, PartialEq)]
pub enum Effect {}

/// **The complete observable state (FC-1 / FC-3)** of a [`GitView`], in one
/// serializable, round-trippable struct: the repo snapshot, the open source blob,
/// the selected branch / path (keyed on stable **names**, FC-5), the visible pane,
/// and the source zoom. [`GitView::state`] hands back a `&GitState`; a headless
/// driver ([`facett_core::harness`]) snapshots it after feeding a `Vec<Msg>`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct GitState {
    /// The repository snapshot the facet renders (branches / commits / tree).
    pub snap: RepoSnapshot,
    /// The open source blob (set by the host, or by `gix` when a file is clicked).
    pub blob: Option<Blob>,
    /// The selected branch name (FC-5: keyed on the name, not the row index).
    pub selected_branch: Option<String>,
    /// The open tree path (FC-5: keyed on the entry name).
    pub selected_path: Option<String>,
    /// Which middle pane is showing (tree / log).
    pub pane: Pane,
    /// Source zoom factor (`0.5..=4.0`).
    pub scale: f32,
}

/// The git repository facet.
pub struct GitView {
    title: String,
    /// All observable state (FC-3).
    state: GitState,
}

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(),
            state: GitState {
                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)?))
    }

    /// **FC-3** — read the complete observable state at any frame boundary.
    #[must_use]
    pub fn state(&self) -> &GitState {
        &self.state
    }

    /// **FC-2 / FC-8** — the single mutation path. Apply one [`Msg`]; returns the
    /// (always empty) [`Effect`]s the host should run. Every canvas click routes its
    /// [`Msg`] here too, so live == headless.
    pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
        match msg {
            Msg::SelectBranch(name) => self.state.selected_branch = Some(name),
            Msg::SelectPath(path) => self.state.selected_path = Some(path),
            Msg::ShowPane(pane) => self.state.pane = pane,
            Msg::SetScale(scale) => self.state.scale = scale.clamp(0.5, 4.0),
            Msg::SetBlob(blob) => {
                self.state.selected_path = Some(blob.path.clone());
                self.state.blob = Some(blob);
            }
        }
        Vec::new()
    }

    /// Replace the open source blob (the source pane content). A thin wrapper over
    /// the FC-2 [`update`](Self::update) mutation path.
    pub fn set_blob(&mut self, blob: Blob) {
        let _ = self.update(Msg::SetBlob(blob));
    }

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

    /// The currently selected branch name.
    #[must_use]
    pub fn selected_branch(&self) -> Option<&str> {
        self.state.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.state.selected_path.as_deref() {
            return Some(path.to_string());
        }
        if let Some(b) =
            self.state.selected_branch.as_deref().and_then(|n| self.state.snap.branches.iter().find(|b| b.name == n))
        {
            return Some(format!("{} ({})", b.name, b.tip));
        }
        if self.state.snap.commits.is_empty() {
            return None;
        }
        let mut out = String::from("id\tsummary\tauthor\ttime");
        for c in &self.state.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 GitView {
    /// **FC-9 render** — a **pure** function of `&self`: it paints the three columns
    /// (branches / tree-or-log / source) and *returns* the [`Msg`]s the interactions
    /// produced. It MUST NOT mutate the [`GitState`] or do I/O; the
    /// [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm) bridge applies the
    /// returned messages through [`update`](Self::update).
    pub fn view(&self, ui: &mut Ui) -> Vec<Msg> {
        let mut msgs: Vec<Msg> = Vec::new();
        let theme = Theme::default();
        ui.horizontal(|ui| {
            // ── branches column ──
            ui.vertical(|ui| {
                ui.strong("Branches");
                for b in &self.state.snap.branches {
                    let label = if b.is_head { format!("● {} ({})", b.name, b.tip) } else { format!("  {} ({})", b.name, b.tip) };
                    let sel = self.state.selected_branch.as_deref() == Some(b.name.as_str());
                    if ui.selectable_label(sel, label).clicked() {
                        msgs.push(Msg::SelectBranch(b.name.clone()));
                    }
                }
            });
            ui.separator();
            // ── tree / log column ──
            ui.vertical(|ui| {
                ui.horizontal(|ui| {
                    if ui.selectable_label(self.state.pane == Pane::Tree, "Tree").clicked() {
                        msgs.push(Msg::ShowPane(Pane::Tree));
                    }
                    if ui.selectable_label(self.state.pane == Pane::Log, "Log").clicked() {
                        msgs.push(Msg::ShowPane(Pane::Log));
                    }
                });
                match self.state.pane {
                    Pane::Tree => {
                        for e in &self.state.snap.tree {
                            let icon = if e.is_dir { "📁" } else { "📄" };
                            let sel = self.state.selected_path.as_deref() == Some(e.name.as_str());
                            if ui.selectable_label(sel, format!("{icon} {}", e.name)).clicked() {
                                msgs.push(Msg::SelectPath(e.name.clone()));
                            }
                        }
                    }
                    Pane::Log => {
                        for c in &self.state.snap.commits {
                            ui.label(format!("{} {} — {}", c.id, c.summary, c.author));
                        }
                    }
                }
            });
            ui.separator();
            // ── source column ──
            ui.vertical(|ui| {
                ui.strong(self.state.selected_path.as_deref().unwrap_or("(no file)"));
                if let Some(blob) = &self.state.blob {
                    if blob.binary {
                        ui.weak("binary file");
                    } else {
                        let job = GitView::highlight_job(&blob.text, &theme, FontId::monospace(12.0 * self.state.scale));
                        ui.label(job);
                    }
                }
            });
        });

        #[cfg(feature = "testmatrix")]
        facett_core::testmatrix::emit(
            "facett-git::GitView::view",
            "ui_render",
            true,
            &format!(
                "branches={} commits={} tree={} pane={}",
                self.state.snap.branches.len(),
                self.state.snap.commits.len(),
                self.state.snap.tree.len(),
                match self.state.pane {
                    Pane::Tree => "tree",
                    Pane::Log => "log",
                },
            ),
        );

        msgs
    }
}

// ── FC-2 / FC-3 / FC-8 / FC-9: the canonical Elm split ────────────────────────
impl facett_core::Elm for GitView {
    type Model = GitState;
    type Msg = Msg;
    type Effect = Effect;

    fn title(&self) -> &str {
        &self.title
    }
    fn state(&self) -> &GitState {
        &self.state
    }
    fn update(&mut self, msg: Msg) -> Vec<Effect> {
        GitView::update(self, msg)
    }
    fn view(&self, ui: &mut Ui) -> Vec<Msg> {
        GitView::view(self, ui)
    }
}

// The bridge macro writes `impl Facet for GitView` from the `Elm` impl: `title`, the
// FC-9 `ui` loop (`for m in view(ui) { update(m) }`), plus the extra overrides below.
// **Form 3** (`custom_state_json`) because the git browser publishes a RICHER
// `state_json` than a plain `serde(state())`: the derived counts (`branches` /
// `commits` / `tree_entries`), the resolved `head` branch, and the flattened
// selection are computed from the snapshot, not dumped from the raw Model — so the
// default `state_json` is suppressed and this one supplied (keys preserved exactly).
facett_core::impl_facet_via_elm!(GitView, custom_state_json, {
    fn copy(&mut self) -> Option<String> {
        self.copy_payload().map(|p| p.as_text())
    }

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

    /// The robot / headless-drive seam (FC-2): parse one serde-tagged [`Msg`] and
    /// apply it through the single [`Elm::update`](facett_core::Elm::update)
    /// mutation path. The `impl_facet_via_elm!` bridge does NOT generate an
    /// `update_json`, so without this override the JSON drive seam is a dead no-op
    /// (a headless robot's branch / path / pane messages mutate nothing). Malformed
    /// / unknown JSON is ignored (never a panic).
    fn update_json(&mut self, msg_json: &str) {
        if let Ok(msg) = serde_json::from_str::<Msg>(msg_json) {
            let _ = <Self as facett_core::Elm>::update(self, msg);
        }
    }

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

    fn scale(&self) -> f32 {
        self.state.scale
    }
    fn set_scale(&mut self, scale: f32) {
        let _ = self.update(Msg::SetScale(scale));
    }

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

#[cfg(test)]
mod tests {
    use super::*;
    // `Facet` for `state_json`/`selection_json`/`scale`/`set_scale`/`caps`/`title`.
    use facett_core::Facet;

    #[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);
    }

    /// **RED-when-broken: the uniform scale knob reaches the PIXELS.**
    ///
    /// The clamp test above is a state round-trip. Measured: deleting the scale
    /// application from the painter outright left this whole crate's suite green, and
    /// `RenderReport::drew()` cannot see it either (a glyph is four vertices at 6pt and
    /// at 60pt). This asserts APPLIED geometry: the area of the glyph triangles the
    /// painter really emitted, which must grow roughly 4x when the scale doubles.
    #[test]
    fn scale_is_applied_to_the_drawn_blob_not_just_stored() {
        use facett_core::harness;
        let ink = |s: f32| {
            let mut v = { let mut v = GitView::demo(); v.set_blob(Blob { path: "src/lib.rs".into(), text: "fn main() { let x = 1; }\nfn other() { let y = 2; }\n".repeat(8), binary: false }); v };
            <_ as facett_core::Facet>::set_scale(&mut v, s);
            harness::headless_render(&mut v).textured_ink_area
        };
        let (small, big) = (ink(1.0), ink(2.0));
        assert!(small > 0.0, "the facet draws text at 1.0x ({small})");
        assert!(
            big > small * 2.0,
            "doubling the scale must roughly QUADRUPLE the glyph ink, got {small:.0} -> \
             {big:.0} pt^2 ({:.2}x). A ratio near 1.0 means the knob is stored, clamped \
             and serialised but never applied to anything drawn.",
            big / small,
        );
    }

    /// 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.
        // epaint 0.35 types `byte_range` as `Range<ByteIndex>` — a newtype over usize
        // (`ByteIndex(pub usize)`), so that a byte offset cannot be silently confused
        // with the character offset `CCursor` counts. Unwrap it to index the `str`.
        let kw = job
            .sections
            .iter()
            .find(|s| job.text[s.byte_range.start.0..s.byte_range.end.0].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);
    }

    // ── FC-2 → FC-3 as a *headless* property: feed a `Vec<Msg>` through the core
    //    harness and assert the resulting `GitState` — no egui, no GPU. ────────────

    #[test]
    fn harness_snapshot_drives_selection_and_pane() {
        use facett_core::harness;
        let mut v = GitView::demo();
        // Pick a branch, open a path, switch to the log pane — all observable.
        let snap = harness::snapshot(
            &mut v,
            [
                Msg::SelectBranch("feature/x".into()),
                Msg::SelectPath("Cargo.toml".into()),
                Msg::ShowPane(Pane::Log),
            ],
        );
        assert_eq!(snap.selected_branch.as_deref(), Some("feature/x"), "branch selection is observable headlessly");
        assert_eq!(snap.selected_path.as_deref(), Some("Cargo.toml"), "path selection (FC-5: keyed on name)");
        assert_eq!(snap.pane, Pane::Log, "pane switch is observable");
        assert_eq!(&snap, v.state(), "the snapshot is a clone of the live state");
    }

    #[test]
    fn drive_reports_no_effects_and_state_round_trips() {
        use facett_core::harness;
        let mut v = GitView::demo();
        // FC-8: the git browser does no I/O, so the effect stream is always empty.
        let effects = harness::drive(
            &mut v,
            [
                Msg::SelectBranch("feature/x".into()),
                Msg::SetBlob(Blob { path: "src/main.rs".into(), text: "fn main() {}".into(), binary: false }),
                Msg::SetScale(2.0),
            ],
        );
        assert!(effects.is_empty(), "FC-8: git emits no Effects");
        // The blob load flows through update (open_blob + selected_path both set).
        assert_eq!(v.state_json()["open_blob"], "src/main.rs");
        assert_eq!(v.state_json()["selected_path"], "src/main.rs");
        assert_eq!(v.state().scale, 2.0);
        // FC-3: the observable Model round-trips through serde.
        let json = serde_json::to_value(v.state()).unwrap();
        let back: GitState = serde_json::from_value(json).unwrap();
        assert_eq!(&back, v.state(), "serde(state) -> state round-trips");
    }

    /// The bridge macro's `state_json` still exposes the exact rich keys the deck
    /// reads, and a headless render draws + reports them (LAW 6).
    #[test]
    fn headless_render_reports_rich_state() {
        use facett_core::harness;
        let mut v = GitView::demo();
        v.set_blob(Blob { path: "src/lib.rs".into(), text: "fn x() -> u32 { 7 }".into(), binary: false });
        let r = harness::headless_render(&mut v);
        assert_eq!(r.title, "Git");
        assert!(r.drew(), "the three columns tessellate to vertices");
        assert_eq!(r.state["branches"], 2);
        assert_eq!(r.state["open_blob"], "src/lib.rs");
        assert_eq!(r.state["pane"], "tree");
    }
}