Skip to main content

facett_git/
lib.rs

1//! **`facett-git`** — a facett [`Facet`](facett_core::Facet) that shows a git
2//! repository: its **branches**, the **commit log**, the **file tree**, and
3//! **syntax-coloured source** — all clickable. A consumer (e.g. nornir's viz) drops
4//! it into a `FacetDeck` to surface git for any repo.
5//!
6//! - The repository data is a pure [`RepoSnapshot`] (branches / commits / tree). With
7//!   the **`gix` feature** it is read live from disk via
8//!   [`RepoSnapshot::open`](model::RepoSnapshot::open); without it the host supplies
9//!   a snapshot (and a blob), so the facet builds and tests with no git dependency.
10//! - Source is rendered with a deterministic Rust [`highlight`]er into an egui
11//!   `LayoutJob` (egui *can* render coloured Rust text — this is how).
12//! - Everything observable (selected branch, open path, counts) is in
13//!   [`state_json`](GitView::state_json) (LAW 6 / FC-6), so it is headless-testable.
14//!
15//! ## FC contract (the canonical Elm split, FC-2 / FC-9)
16//! - **[`GitState`]** — the complete observable, serializable, round-trippable state
17//!   (the repo snapshot, the open blob, the selected branch / path, the visible pane,
18//!   the zoom). [`GitView::state`] hands back a `&GitState`.
19//! - **[`Msg`] + [`GitView::update`]** — the single mutation path (FC-2): the same
20//!   gestures the canvas clicks drive (pick a branch, open a tree path, switch pane,
21//!   zoom, load a blob), so a headless driver ([`facett_core::harness`]) reaches them.
22//! - **[`GitView::view`]** — a **pure** paint (FC-9): it reads `&self`, paints the
23//!   three columns, and *returns* the [`Msg`]s the interactions produced; the
24//!   [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm) bridge applies them.
25//! - **Rich `state_json`** — the derived counts (`branches` / `commits` /
26//!   `tree_entries`), the head branch, and the flattened selection a driver reads are
27//!   *derived* from the snapshot, not a plain `serde(state())`, so the macro is
28//!   invoked in **form 3** (`custom_state_json`) to publish those keys.
29
30pub mod highlight;
31pub mod model;
32
33pub use highlight::{highlight, Kind, Span};
34pub use model::{Blob, Branch, CommitRow, RepoSnapshot, TreeEntry};
35
36use egui::{text::LayoutJob, Color32, FontId, TextFormat, Ui};
37use facett_core::clip::{ClipKind, ClipPayload, CopySource};
38use serde::{Deserialize, Serialize};
39
40/// Which middle-pane view is showing. Part of the observable [`GitState`], so it is
41/// serializable; the string form the deck reads (`"tree"` / `"log"`) is emitted by
42/// [`GitView::state_json`].
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "lowercase")]
45pub enum Pane {
46    Tree,
47    Log,
48}
49
50/// A robot-/CLI-addressable control message (FC-2) — the named boundary a headless
51/// driver (or a host) drives the git browser through, the same effect the canvas
52/// clicks produce. Applied by [`GitView::update`]; it is the **only** mutation path.
53#[derive(Clone, Debug, PartialEq)]
54pub enum Msg {
55    /// Select a branch by its **name** (FC-5: a stable id, never a row index) — the
56    /// branch-list click.
57    SelectBranch(String),
58    /// Open a tree path by its **name** (FC-5: a stable id) — the tree-entry click.
59    SelectPath(String),
60    /// Switch the middle pane between the tree and the commit log — the tab click.
61    ShowPane(Pane),
62    /// Set the source zoom (clamped to `0.5..=4.0`) — the host `set_scale`.
63    SetScale(f32),
64    /// Replace the open source blob (and select its path) — the host `set_blob` /
65    /// the `gix` file-open path.
66    SetBlob(Blob),
67}
68
69/// Side work as data (FC-8). The git browser does no I/O — every [`Msg`] mutates
70/// only the in-memory [`GitState`] — so this is uninhabited on purpose: the
71/// type-checked statement that [`GitView::update`] never asks the host to do
72/// anything. (Live repo reads happen up-front in [`RepoSnapshot::open`], not here.)
73#[derive(Clone, Debug, PartialEq)]
74pub enum Effect {}
75
76/// **The complete observable state (FC-1 / FC-3)** of a [`GitView`], in one
77/// serializable, round-trippable struct: the repo snapshot, the open source blob,
78/// the selected branch / path (keyed on stable **names**, FC-5), the visible pane,
79/// and the source zoom. [`GitView::state`] hands back a `&GitState`; a headless
80/// driver ([`facett_core::harness`]) snapshots it after feeding a `Vec<Msg>`.
81#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
82pub struct GitState {
83    /// The repository snapshot the facet renders (branches / commits / tree).
84    pub snap: RepoSnapshot,
85    /// The open source blob (set by the host, or by `gix` when a file is clicked).
86    pub blob: Option<Blob>,
87    /// The selected branch name (FC-5: keyed on the name, not the row index).
88    pub selected_branch: Option<String>,
89    /// The open tree path (FC-5: keyed on the entry name).
90    pub selected_path: Option<String>,
91    /// Which middle pane is showing (tree / log).
92    pub pane: Pane,
93    /// Source zoom factor (`0.5..=4.0`).
94    pub scale: f32,
95}
96
97/// The git repository facet.
98pub struct GitView {
99    title: String,
100    /// All observable state (FC-3).
101    state: GitState,
102}
103
104impl GitView {
105    /// A facet over an in-memory [`RepoSnapshot`] (host-supplied data path).
106    #[must_use]
107    pub fn new(snap: RepoSnapshot) -> Self {
108        let selected_branch = snap.head_branch().map(str::to_string);
109        Self {
110            title: "Git".to_string(),
111            state: GitState {
112                snap,
113                blob: None,
114                selected_branch,
115                selected_path: None,
116                pane: Pane::Tree,
117                scale: 1.0,
118            },
119        }
120    }
121
122    /// A demo facet (no git needed) — handy for examples/snapshot tests.
123    #[must_use]
124    pub fn demo() -> Self {
125        Self::new(RepoSnapshot::demo())
126    }
127
128    /// Open a repo live (requires the `gix` feature). `max_commits` caps the log.
129    #[cfg(feature = "gix")]
130    pub fn open(path: &str, max_commits: usize) -> Result<Self, String> {
131        Ok(Self::new(RepoSnapshot::open(path, max_commits)?))
132    }
133
134    /// **FC-3** — read the complete observable state at any frame boundary.
135    #[must_use]
136    pub fn state(&self) -> &GitState {
137        &self.state
138    }
139
140    /// **FC-2 / FC-8** — the single mutation path. Apply one [`Msg`]; returns the
141    /// (always empty) [`Effect`]s the host should run. Every canvas click routes its
142    /// [`Msg`] here too, so live == headless.
143    pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
144        match msg {
145            Msg::SelectBranch(name) => self.state.selected_branch = Some(name),
146            Msg::SelectPath(path) => self.state.selected_path = Some(path),
147            Msg::ShowPane(pane) => self.state.pane = pane,
148            Msg::SetScale(scale) => self.state.scale = scale.clamp(0.5, 4.0),
149            Msg::SetBlob(blob) => {
150                self.state.selected_path = Some(blob.path.clone());
151                self.state.blob = Some(blob);
152            }
153        }
154        Vec::new()
155    }
156
157    /// Replace the open source blob (the source pane content). A thin wrapper over
158    /// the FC-2 [`update`](Self::update) mutation path.
159    pub fn set_blob(&mut self, blob: Blob) {
160        let _ = self.update(Msg::SetBlob(blob));
161    }
162
163    /// The current snapshot (read-only).
164    #[must_use]
165    pub fn snapshot(&self) -> &RepoSnapshot {
166        &self.state.snap
167    }
168
169    /// The currently selected branch name.
170    #[must_use]
171    pub fn selected_branch(&self) -> Option<&str> {
172        self.state.selected_branch.as_deref()
173    }
174
175    /// Build a syntax-coloured `LayoutJob` for Rust `src` under `theme`. Public so a
176    /// host can render code with the same colours outside the facet.
177    #[must_use]
178    pub fn highlight_job(src: &str, theme: &Theme, font: FontId) -> LayoutJob {
179        let mut job = LayoutJob::default();
180        for span in highlight(src) {
181            let fmt = TextFormat { font_id: font.clone(), color: theme.color(span.kind), ..Default::default() };
182            job.append(&src[span.start..span.end], 0.0, fmt);
183        }
184        job
185    }
186}
187
188/// Colours for each syntax [`Kind`]. [`Default`] is a dark theme.
189#[derive(Debug, Clone, Copy)]
190pub struct Theme {
191    pub text: Color32,
192    pub keyword: Color32,
193    pub ty: Color32,
194    pub string: Color32,
195    pub number: Color32,
196    pub comment: Color32,
197    pub attribute: Color32,
198    pub punct: Color32,
199}
200
201impl Default for Theme {
202    fn default() -> Self {
203        Self {
204            text: Color32::from_rgb(0xD4, 0xD4, 0xD4),
205            keyword: Color32::from_rgb(0x56, 0x9C, 0xD6),
206            ty: Color32::from_rgb(0x4E, 0xC9, 0xB0),
207            string: Color32::from_rgb(0xCE, 0x91, 0x78),
208            number: Color32::from_rgb(0xB5, 0xCE, 0xA8),
209            comment: Color32::from_rgb(0x6A, 0x99, 0x55),
210            attribute: Color32::from_rgb(0xDC, 0xDC, 0xAA),
211            punct: Color32::from_rgb(0xD4, 0xD4, 0xD4),
212        }
213    }
214}
215
216impl Theme {
217    /// The colour for a syntax kind.
218    #[must_use]
219    pub fn color(&self, kind: Kind) -> Color32 {
220        match kind {
221            Kind::Text => self.text,
222            Kind::Keyword => self.keyword,
223            Kind::Type => self.ty,
224            Kind::Str => self.string,
225            Kind::Number => self.number,
226            Kind::Comment => self.comment,
227            Kind::Attribute => self.attribute,
228            Kind::Punct => self.punct,
229        }
230    }
231}
232
233impl GitView {
234    /// The copyable text: the selected file path if one is open, else the selected
235    /// branch name + tip, else the commit log as a TSV rectangle
236    /// (`id \t summary \t author \t time`).
237    pub fn copy_text(&self) -> Option<String> {
238        if let Some(path) = self.state.selected_path.as_deref() {
239            return Some(path.to_string());
240        }
241        if let Some(b) =
242            self.state.selected_branch.as_deref().and_then(|n| self.state.snap.branches.iter().find(|b| b.name == n))
243        {
244            return Some(format!("{} ({})", b.name, b.tip));
245        }
246        if self.state.snap.commits.is_empty() {
247            return None;
248        }
249        let mut out = String::from("id\tsummary\tauthor\ttime");
250        for c in &self.state.snap.commits {
251            out.push('\n');
252            out.push_str(&format!("{}\t{}\t{}\t{}", c.id, c.summary, c.author, c.time));
253        }
254        Some(out)
255    }
256}
257
258// ── typed copy (§16) — read-only git browser: path / branch / commit-log rows ──
259impl CopySource for GitView {
260    fn copy_kinds(&self) -> &[ClipKind] {
261        &[ClipKind::Text]
262    }
263
264    fn copy_payload(&self) -> Option<ClipPayload> {
265        self.copy_text().map(ClipPayload::Text)
266    }
267}
268
269impl GitView {
270    /// **FC-9 render** — a **pure** function of `&self`: it paints the three columns
271    /// (branches / tree-or-log / source) and *returns* the [`Msg`]s the interactions
272    /// produced. It MUST NOT mutate the [`GitState`] or do I/O; the
273    /// [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm) bridge applies the
274    /// returned messages through [`update`](Self::update).
275    pub fn view(&self, ui: &mut Ui) -> Vec<Msg> {
276        let mut msgs: Vec<Msg> = Vec::new();
277        let theme = Theme::default();
278        ui.horizontal(|ui| {
279            // ── branches column ──
280            ui.vertical(|ui| {
281                ui.strong("Branches");
282                for b in &self.state.snap.branches {
283                    let label = if b.is_head { format!("● {} ({})", b.name, b.tip) } else { format!("  {} ({})", b.name, b.tip) };
284                    let sel = self.state.selected_branch.as_deref() == Some(b.name.as_str());
285                    if ui.selectable_label(sel, label).clicked() {
286                        msgs.push(Msg::SelectBranch(b.name.clone()));
287                    }
288                }
289            });
290            ui.separator();
291            // ── tree / log column ──
292            ui.vertical(|ui| {
293                ui.horizontal(|ui| {
294                    if ui.selectable_label(self.state.pane == Pane::Tree, "Tree").clicked() {
295                        msgs.push(Msg::ShowPane(Pane::Tree));
296                    }
297                    if ui.selectable_label(self.state.pane == Pane::Log, "Log").clicked() {
298                        msgs.push(Msg::ShowPane(Pane::Log));
299                    }
300                });
301                match self.state.pane {
302                    Pane::Tree => {
303                        for e in &self.state.snap.tree {
304                            let icon = if e.is_dir { "📁" } else { "📄" };
305                            let sel = self.state.selected_path.as_deref() == Some(e.name.as_str());
306                            if ui.selectable_label(sel, format!("{icon} {}", e.name)).clicked() {
307                                msgs.push(Msg::SelectPath(e.name.clone()));
308                            }
309                        }
310                    }
311                    Pane::Log => {
312                        for c in &self.state.snap.commits {
313                            ui.label(format!("{} {} — {}", c.id, c.summary, c.author));
314                        }
315                    }
316                }
317            });
318            ui.separator();
319            // ── source column ──
320            ui.vertical(|ui| {
321                ui.strong(self.state.selected_path.as_deref().unwrap_or("(no file)"));
322                if let Some(blob) = &self.state.blob {
323                    if blob.binary {
324                        ui.weak("binary file");
325                    } else {
326                        let job = GitView::highlight_job(&blob.text, &theme, FontId::monospace(12.0 * self.state.scale));
327                        ui.label(job);
328                    }
329                }
330            });
331        });
332
333        #[cfg(feature = "testmatrix")]
334        facett_core::testmatrix::emit(
335            "facett-git::GitView::view",
336            "ui_render",
337            true,
338            &format!(
339                "branches={} commits={} tree={} pane={}",
340                self.state.snap.branches.len(),
341                self.state.snap.commits.len(),
342                self.state.snap.tree.len(),
343                match self.state.pane {
344                    Pane::Tree => "tree",
345                    Pane::Log => "log",
346                },
347            ),
348        );
349
350        msgs
351    }
352}
353
354// ── FC-2 / FC-3 / FC-8 / FC-9: the canonical Elm split ────────────────────────
355impl facett_core::Elm for GitView {
356    type Model = GitState;
357    type Msg = Msg;
358    type Effect = Effect;
359
360    fn title(&self) -> &str {
361        &self.title
362    }
363    fn state(&self) -> &GitState {
364        &self.state
365    }
366    fn update(&mut self, msg: Msg) -> Vec<Effect> {
367        GitView::update(self, msg)
368    }
369    fn view(&self, ui: &mut Ui) -> Vec<Msg> {
370        GitView::view(self, ui)
371    }
372}
373
374// The bridge macro writes `impl Facet for GitView` from the `Elm` impl: `title`, the
375// FC-9 `ui` loop (`for m in view(ui) { update(m) }`), plus the extra overrides below.
376// **Form 3** (`custom_state_json`) because the git browser publishes a RICHER
377// `state_json` than a plain `serde(state())`: the derived counts (`branches` /
378// `commits` / `tree_entries`), the resolved `head` branch, and the flattened
379// selection are computed from the snapshot, not dumped from the raw Model — so the
380// default `state_json` is suppressed and this one supplied (keys preserved exactly).
381facett_core::impl_facet_via_elm!(GitView, custom_state_json, {
382    fn copy(&mut self) -> Option<String> {
383        self.copy_payload().map(|p| p.as_text())
384    }
385
386    fn state_json(&self) -> serde_json::Value {
387        serde_json::json!({
388            "title": self.title,
389            "workdir": self.state.snap.workdir,
390            "branches": self.state.snap.branches.len(),
391            "head": self.state.snap.head_branch(),
392            "commits": self.state.snap.commits.len(),
393            "tree_entries": self.state.snap.tree.len(),
394            "pane": match self.state.pane { Pane::Tree => "tree", Pane::Log => "log" },
395            "selected_branch": self.state.selected_branch,
396            "selected_path": self.state.selected_path,
397            "open_blob": self.state.blob.as_ref().map(|b| &b.path),
398            "scale": self.state.scale,
399        })
400    }
401
402    fn caps(&self) -> facett_core::FacetCaps {
403        facett_core::FacetCaps::NONE.scalable().selectable().themeable().copyable()
404    }
405
406    fn scale(&self) -> f32 {
407        self.state.scale
408    }
409    fn set_scale(&mut self, scale: f32) {
410        let _ = self.update(Msg::SetScale(scale));
411    }
412
413    fn selection_json(&self) -> serde_json::Value {
414        serde_json::json!({ "branch": self.state.selected_branch, "path": self.state.selected_path })
415    }
416});
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421    // `Facet` for `state_json`/`selection_json`/`scale`/`set_scale`/`caps`/`title`.
422    use facett_core::Facet;
423
424    #[test]
425    fn typed_copy_prefers_path_then_branch_then_commit_log() {
426        use facett_core::clip::{ClipKind, CopySource};
427        let mut v = GitView::demo();
428        // Head branch is pre-selected -> copy is the branch name + tip.
429        let p = v.copy_payload().expect("a demo repo copies");
430        assert_eq!(p.kind(), ClipKind::Text);
431        let head = v.selected_branch().unwrap().to_string();
432        assert!(p.as_text().starts_with(&head), "branch copy: {}", p.as_text());
433        // Open a blob -> copy prefers the selected file path.
434        v.set_blob(Blob { path: "src/main.rs".into(), text: "fn main() {}".into(), binary: false });
435        assert_eq!(v.copy_payload().unwrap().as_text(), "src/main.rs");
436    }
437
438    /// INJECT-ASSERT: state_json exposes every visible count + the selection (LAW 6
439    /// headless surface), and the head branch is pre-selected.
440    #[test]
441    fn state_json_exposes_counts_and_selection() {
442        let v = GitView::demo();
443        let s = v.state_json();
444        assert_eq!(s["branches"], 2);
445        assert_eq!(s["commits"], 2);
446        assert_eq!(s["tree_entries"], 3);
447        assert_eq!(s["head"], "main");
448        assert_eq!(s["selected_branch"], "main");
449        assert_eq!(s["pane"], "tree");
450    }
451
452    /// INJECT-ASSERT: setting a blob updates the open path + state, and scale clamps.
453    #[test]
454    fn set_blob_and_scale_clamp() {
455        let mut v = GitView::demo();
456        v.set_blob(Blob { path: "src/lib.rs".into(), text: "fn x() {}".into(), binary: false });
457        assert_eq!(v.state_json()["open_blob"], "src/lib.rs");
458        assert_eq!(v.selection_json()["path"], "src/lib.rs");
459        v.set_scale(99.0);
460        assert_eq!(v.scale(), 4.0, "scale clamps to the cap");
461        v.set_scale(0.01);
462        assert_eq!(v.scale(), 0.5);
463    }
464
465    /// INJECT-ASSERT: the highlight job carries one coloured section per span and
466    /// the keyword colour differs from plain text (proves colouring is applied).
467    #[test]
468    fn highlight_job_colours_spans() {
469        let theme = Theme::default();
470        let job = GitView::highlight_job("fn main() {}", &theme, FontId::monospace(12.0));
471        assert!(!job.sections.is_empty());
472        // The 'fn' keyword section is the keyword colour, not the default text colour.
473        let kw = job.sections.iter().find(|s| job.text[s.byte_range.clone()].starts_with("fn")).unwrap();
474        assert_eq!(kw.format.color, theme.keyword);
475        assert_ne!(theme.keyword, theme.text);
476    }
477
478    /// INJECT-ASSERT: caps advertise scalable + selectable + themeable.
479    #[test]
480    fn caps_advertised() {
481        let c = GitView::demo().caps();
482        assert!(c.scalable && c.selectable && c.themeable);
483    }
484
485    // ── FC-2 → FC-3 as a *headless* property: feed a `Vec<Msg>` through the core
486    //    harness and assert the resulting `GitState` — no egui, no GPU. ────────────
487
488    #[test]
489    fn harness_snapshot_drives_selection_and_pane() {
490        use facett_core::harness;
491        let mut v = GitView::demo();
492        // Pick a branch, open a path, switch to the log pane — all observable.
493        let snap = harness::snapshot(
494            &mut v,
495            [
496                Msg::SelectBranch("feature/x".into()),
497                Msg::SelectPath("Cargo.toml".into()),
498                Msg::ShowPane(Pane::Log),
499            ],
500        );
501        assert_eq!(snap.selected_branch.as_deref(), Some("feature/x"), "branch selection is observable headlessly");
502        assert_eq!(snap.selected_path.as_deref(), Some("Cargo.toml"), "path selection (FC-5: keyed on name)");
503        assert_eq!(snap.pane, Pane::Log, "pane switch is observable");
504        assert_eq!(&snap, v.state(), "the snapshot is a clone of the live state");
505    }
506
507    #[test]
508    fn drive_reports_no_effects_and_state_round_trips() {
509        use facett_core::harness;
510        let mut v = GitView::demo();
511        // FC-8: the git browser does no I/O, so the effect stream is always empty.
512        let effects = harness::drive(
513            &mut v,
514            [
515                Msg::SelectBranch("feature/x".into()),
516                Msg::SetBlob(Blob { path: "src/main.rs".into(), text: "fn main() {}".into(), binary: false }),
517                Msg::SetScale(2.0),
518            ],
519        );
520        assert!(effects.is_empty(), "FC-8: git emits no Effects");
521        // The blob load flows through update (open_blob + selected_path both set).
522        assert_eq!(v.state_json()["open_blob"], "src/main.rs");
523        assert_eq!(v.state_json()["selected_path"], "src/main.rs");
524        assert_eq!(v.state().scale, 2.0);
525        // FC-3: the observable Model round-trips through serde.
526        let json = serde_json::to_value(v.state()).unwrap();
527        let back: GitState = serde_json::from_value(json).unwrap();
528        assert_eq!(&back, v.state(), "serde(state) -> state round-trips");
529    }
530
531    /// The bridge macro's `state_json` still exposes the exact rich keys the deck
532    /// reads, and a headless render draws + reports them (LAW 6).
533    #[test]
534    fn headless_render_reports_rich_state() {
535        use facett_core::harness;
536        let mut v = GitView::demo();
537        v.set_blob(Blob { path: "src/lib.rs".into(), text: "fn x() -> u32 { 7 }".into(), binary: false });
538        let r = harness::headless_render(&mut v);
539        assert_eq!(r.title, "Git");
540        assert!(r.drew(), "the three columns tessellate to vertices");
541        assert_eq!(r.state["branches"], 2);
542        assert_eq!(r.state["open_blob"], "src/lib.rs");
543        assert_eq!(r.state["pane"], "tree");
544    }
545}