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
15pub mod highlight;
16pub mod model;
17
18pub use highlight::{highlight, Kind, Span};
19pub use model::{Blob, Branch, CommitRow, RepoSnapshot, TreeEntry};
20
21use egui::{text::LayoutJob, Color32, FontId, TextFormat, Ui};
22use facett_core::clip::{ClipKind, ClipPayload, CopySource};
23use facett_core::Facet;
24
25/// Which middle-pane view is showing.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27enum Pane {
28    Tree,
29    Log,
30}
31
32/// The git repository facet.
33pub struct GitView {
34    title: String,
35    snap: RepoSnapshot,
36    /// The open source blob (set by the host, or by `gix` when a file is clicked).
37    blob: Option<Blob>,
38    selected_branch: Option<String>,
39    selected_path: Option<String>,
40    pane: Pane,
41    scale: f32,
42}
43
44impl GitView {
45    /// A facet over an in-memory [`RepoSnapshot`] (host-supplied data path).
46    #[must_use]
47    pub fn new(snap: RepoSnapshot) -> Self {
48        let selected_branch = snap.head_branch().map(str::to_string);
49        Self {
50            title: "Git".to_string(),
51            snap,
52            blob: None,
53            selected_branch,
54            selected_path: None,
55            pane: Pane::Tree,
56            scale: 1.0,
57        }
58    }
59
60    /// A demo facet (no git needed) — handy for examples/snapshot tests.
61    #[must_use]
62    pub fn demo() -> Self {
63        Self::new(RepoSnapshot::demo())
64    }
65
66    /// Open a repo live (requires the `gix` feature). `max_commits` caps the log.
67    #[cfg(feature = "gix")]
68    pub fn open(path: &str, max_commits: usize) -> Result<Self, String> {
69        Ok(Self::new(RepoSnapshot::open(path, max_commits)?))
70    }
71
72    /// Replace the open source blob (the source pane content).
73    pub fn set_blob(&mut self, blob: Blob) {
74        self.selected_path = Some(blob.path.clone());
75        self.blob = Some(blob);
76    }
77
78    /// The current snapshot (read-only).
79    #[must_use]
80    pub fn snapshot(&self) -> &RepoSnapshot {
81        &self.snap
82    }
83
84    /// The currently selected branch name.
85    #[must_use]
86    pub fn selected_branch(&self) -> Option<&str> {
87        self.selected_branch.as_deref()
88    }
89
90    /// Build a syntax-coloured `LayoutJob` for Rust `src` under `theme`. Public so a
91    /// host can render code with the same colours outside the facet.
92    #[must_use]
93    pub fn highlight_job(src: &str, theme: &Theme, font: FontId) -> LayoutJob {
94        let mut job = LayoutJob::default();
95        for span in highlight(src) {
96            let fmt = TextFormat { font_id: font.clone(), color: theme.color(span.kind), ..Default::default() };
97            job.append(&src[span.start..span.end], 0.0, fmt);
98        }
99        job
100    }
101}
102
103/// Colours for each syntax [`Kind`]. [`Default`] is a dark theme.
104#[derive(Debug, Clone, Copy)]
105pub struct Theme {
106    pub text: Color32,
107    pub keyword: Color32,
108    pub ty: Color32,
109    pub string: Color32,
110    pub number: Color32,
111    pub comment: Color32,
112    pub attribute: Color32,
113    pub punct: Color32,
114}
115
116impl Default for Theme {
117    fn default() -> Self {
118        Self {
119            text: Color32::from_rgb(0xD4, 0xD4, 0xD4),
120            keyword: Color32::from_rgb(0x56, 0x9C, 0xD6),
121            ty: Color32::from_rgb(0x4E, 0xC9, 0xB0),
122            string: Color32::from_rgb(0xCE, 0x91, 0x78),
123            number: Color32::from_rgb(0xB5, 0xCE, 0xA8),
124            comment: Color32::from_rgb(0x6A, 0x99, 0x55),
125            attribute: Color32::from_rgb(0xDC, 0xDC, 0xAA),
126            punct: Color32::from_rgb(0xD4, 0xD4, 0xD4),
127        }
128    }
129}
130
131impl Theme {
132    /// The colour for a syntax kind.
133    #[must_use]
134    pub fn color(&self, kind: Kind) -> Color32 {
135        match kind {
136            Kind::Text => self.text,
137            Kind::Keyword => self.keyword,
138            Kind::Type => self.ty,
139            Kind::Str => self.string,
140            Kind::Number => self.number,
141            Kind::Comment => self.comment,
142            Kind::Attribute => self.attribute,
143            Kind::Punct => self.punct,
144        }
145    }
146}
147
148impl GitView {
149    /// The copyable text: the selected file path if one is open, else the selected
150    /// branch name + tip, else the commit log as a TSV rectangle
151    /// (`id \t summary \t author \t time`).
152    pub fn copy_text(&self) -> Option<String> {
153        if let Some(path) = self.selected_path.as_deref() {
154            return Some(path.to_string());
155        }
156        if let Some(b) = self.selected_branch.as_deref().and_then(|n| self.snap.branches.iter().find(|b| b.name == n)) {
157            return Some(format!("{} ({})", b.name, b.tip));
158        }
159        if self.snap.commits.is_empty() {
160            return None;
161        }
162        let mut out = String::from("id\tsummary\tauthor\ttime");
163        for c in &self.snap.commits {
164            out.push('\n');
165            out.push_str(&format!("{}\t{}\t{}\t{}", c.id, c.summary, c.author, c.time));
166        }
167        Some(out)
168    }
169}
170
171// ── typed copy (§16) — read-only git browser: path / branch / commit-log rows ──
172impl CopySource for GitView {
173    fn copy_kinds(&self) -> &[ClipKind] {
174        &[ClipKind::Text]
175    }
176
177    fn copy_payload(&self) -> Option<ClipPayload> {
178        self.copy_text().map(ClipPayload::Text)
179    }
180}
181
182impl Facet for GitView {
183    fn title(&self) -> &str {
184        &self.title
185    }
186
187    fn copy(&mut self) -> Option<String> {
188        self.copy_payload().map(|p| p.as_text())
189    }
190
191    fn ui(&mut self, ui: &mut Ui) {
192        let theme = Theme::default();
193        ui.horizontal(|ui| {
194            // ── branches column ──
195            ui.vertical(|ui| {
196                ui.strong("Branches");
197                for b in &self.snap.branches {
198                    let label = if b.is_head { format!("● {} ({})", b.name, b.tip) } else { format!("  {} ({})", b.name, b.tip) };
199                    let sel = self.selected_branch.as_deref() == Some(b.name.as_str());
200                    if ui.selectable_label(sel, label).clicked() {
201                        self.selected_branch = Some(b.name.clone());
202                    }
203                }
204            });
205            ui.separator();
206            // ── tree / log column ──
207            ui.vertical(|ui| {
208                ui.horizontal(|ui| {
209                    if ui.selectable_label(self.pane == Pane::Tree, "Tree").clicked() {
210                        self.pane = Pane::Tree;
211                    }
212                    if ui.selectable_label(self.pane == Pane::Log, "Log").clicked() {
213                        self.pane = Pane::Log;
214                    }
215                });
216                match self.pane {
217                    Pane::Tree => {
218                        for e in &self.snap.tree {
219                            let icon = if e.is_dir { "📁" } else { "📄" };
220                            let sel = self.selected_path.as_deref() == Some(e.name.as_str());
221                            if ui.selectable_label(sel, format!("{icon} {}", e.name)).clicked() {
222                                self.selected_path = Some(e.name.clone());
223                            }
224                        }
225                    }
226                    Pane::Log => {
227                        for c in &self.snap.commits {
228                            ui.label(format!("{} {} — {}", c.id, c.summary, c.author));
229                        }
230                    }
231                }
232            });
233            ui.separator();
234            // ── source column ──
235            ui.vertical(|ui| {
236                ui.strong(self.selected_path.as_deref().unwrap_or("(no file)"));
237                if let Some(blob) = &self.blob {
238                    if blob.binary {
239                        ui.weak("binary file");
240                    } else {
241                        let job = GitView::highlight_job(&blob.text, &theme, FontId::monospace(12.0 * self.scale));
242                        ui.label(job);
243                    }
244                }
245            });
246        });
247    }
248
249    fn state_json(&self) -> serde_json::Value {
250        serde_json::json!({
251            "title": self.title,
252            "workdir": self.snap.workdir,
253            "branches": self.snap.branches.len(),
254            "head": self.snap.head_branch(),
255            "commits": self.snap.commits.len(),
256            "tree_entries": self.snap.tree.len(),
257            "pane": match self.pane { Pane::Tree => "tree", Pane::Log => "log" },
258            "selected_branch": self.selected_branch,
259            "selected_path": self.selected_path,
260            "open_blob": self.blob.as_ref().map(|b| &b.path),
261            "scale": self.scale,
262        })
263    }
264
265    fn caps(&self) -> facett_core::FacetCaps {
266        facett_core::FacetCaps::NONE.scalable().selectable().themeable().copyable()
267    }
268
269    fn scale(&self) -> f32 {
270        self.scale
271    }
272    fn set_scale(&mut self, scale: f32) {
273        self.scale = scale.clamp(0.5, 4.0);
274    }
275
276    fn selection_json(&self) -> serde_json::Value {
277        serde_json::json!({ "branch": self.selected_branch, "path": self.selected_path })
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn typed_copy_prefers_path_then_branch_then_commit_log() {
287        use facett_core::clip::{ClipKind, CopySource};
288        let mut v = GitView::demo();
289        // Head branch is pre-selected -> copy is the branch name + tip.
290        let p = v.copy_payload().expect("a demo repo copies");
291        assert_eq!(p.kind(), ClipKind::Text);
292        let head = v.selected_branch().unwrap().to_string();
293        assert!(p.as_text().starts_with(&head), "branch copy: {}", p.as_text());
294        // Open a blob -> copy prefers the selected file path.
295        v.set_blob(Blob { path: "src/main.rs".into(), text: "fn main() {}".into(), binary: false });
296        assert_eq!(v.copy_payload().unwrap().as_text(), "src/main.rs");
297    }
298
299    /// INJECT-ASSERT: state_json exposes every visible count + the selection (LAW 6
300    /// headless surface), and the head branch is pre-selected.
301    #[test]
302    fn state_json_exposes_counts_and_selection() {
303        let v = GitView::demo();
304        let s = v.state_json();
305        assert_eq!(s["branches"], 2);
306        assert_eq!(s["commits"], 2);
307        assert_eq!(s["tree_entries"], 3);
308        assert_eq!(s["head"], "main");
309        assert_eq!(s["selected_branch"], "main");
310        assert_eq!(s["pane"], "tree");
311    }
312
313    /// INJECT-ASSERT: setting a blob updates the open path + state, and scale clamps.
314    #[test]
315    fn set_blob_and_scale_clamp() {
316        let mut v = GitView::demo();
317        v.set_blob(Blob { path: "src/lib.rs".into(), text: "fn x() {}".into(), binary: false });
318        assert_eq!(v.state_json()["open_blob"], "src/lib.rs");
319        assert_eq!(v.selection_json()["path"], "src/lib.rs");
320        v.set_scale(99.0);
321        assert_eq!(v.scale(), 4.0, "scale clamps to the cap");
322        v.set_scale(0.01);
323        assert_eq!(v.scale(), 0.5);
324    }
325
326    /// INJECT-ASSERT: the highlight job carries one coloured section per span and
327    /// the keyword colour differs from plain text (proves colouring is applied).
328    #[test]
329    fn highlight_job_colours_spans() {
330        let theme = Theme::default();
331        let job = GitView::highlight_job("fn main() {}", &theme, FontId::monospace(12.0));
332        assert!(!job.sections.is_empty());
333        // The 'fn' keyword section is the keyword colour, not the default text colour.
334        let kw = job.sections.iter().find(|s| job.text[s.byte_range.clone()].starts_with("fn")).unwrap();
335        assert_eq!(kw.format.color, theme.keyword);
336        assert_ne!(theme.keyword, theme.text);
337    }
338
339    /// INJECT-ASSERT: caps advertise scalable + selectable + themeable.
340    #[test]
341    fn caps_advertised() {
342        let c = GitView::demo().caps();
343        assert!(c.scalable && c.selectable && c.themeable);
344    }
345}