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");
}
}