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};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Pane {
Tree,
Log,
}
#[derive(Clone, Debug, PartialEq)]
pub enum Msg {
SelectBranch(String),
SelectPath(String),
ShowPane(Pane),
SetScale(f32),
SetBlob(Blob),
}
#[derive(Clone, Debug, PartialEq)]
pub enum Effect {}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct GitState {
pub snap: RepoSnapshot,
pub blob: Option<Blob>,
pub selected_branch: Option<String>,
pub selected_path: Option<String>,
pub pane: Pane,
pub scale: f32,
}
pub struct GitView {
title: String,
state: GitState,
}
impl GitView {
#[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,
},
}
}
#[must_use]
pub fn demo() -> Self {
Self::new(RepoSnapshot::demo())
}
#[cfg(feature = "gix")]
pub fn open(path: &str, max_commits: usize) -> Result<Self, String> {
Ok(Self::new(RepoSnapshot::open(path, max_commits)?))
}
#[must_use]
pub fn state(&self) -> &GitState {
&self.state
}
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()
}
pub fn set_blob(&mut self, blob: Blob) {
let _ = self.update(Msg::SetBlob(blob));
}
#[must_use]
pub fn snapshot(&self) -> &RepoSnapshot {
&self.state.snap
}
#[must_use]
pub fn selected_branch(&self) -> Option<&str> {
self.state.selected_branch.as_deref()
}
#[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
}
}
#[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 {
#[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 {
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)
}
}
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 {
pub fn view(&self, ui: &mut Ui) -> Vec<Msg> {
let mut msgs: Vec<Msg> = Vec::new();
let theme = Theme::default();
ui.horizontal(|ui| {
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();
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();
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
}
}
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)
}
}
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,
})
}
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::*;
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();
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());
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");
}
#[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");
}
#[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);
}
#[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());
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);
}
#[test]
fn caps_advertised() {
let c = GitView::demo().caps();
assert!(c.scalable && c.selectable && c.themeable);
}
#[test]
fn harness_snapshot_drives_selection_and_pane() {
use facett_core::harness;
let mut v = GitView::demo();
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();
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");
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);
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");
}
#[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");
}
}