1pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "lowercase")]
45pub enum Pane {
46 Tree,
47 Log,
48}
49
50#[derive(Clone, Debug, PartialEq)]
54pub enum Msg {
55 SelectBranch(String),
58 SelectPath(String),
60 ShowPane(Pane),
62 SetScale(f32),
64 SetBlob(Blob),
67}
68
69#[derive(Clone, Debug, PartialEq)]
74pub enum Effect {}
75
76#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
82pub struct GitState {
83 pub snap: RepoSnapshot,
85 pub blob: Option<Blob>,
87 pub selected_branch: Option<String>,
89 pub selected_path: Option<String>,
91 pub pane: Pane,
93 pub scale: f32,
95}
96
97pub struct GitView {
99 title: String,
100 state: GitState,
102}
103
104impl GitView {
105 #[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 #[must_use]
124 pub fn demo() -> Self {
125 Self::new(RepoSnapshot::demo())
126 }
127
128 #[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 #[must_use]
136 pub fn state(&self) -> &GitState {
137 &self.state
138 }
139
140 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 pub fn set_blob(&mut self, blob: Blob) {
160 let _ = self.update(Msg::SetBlob(blob));
161 }
162
163 #[must_use]
165 pub fn snapshot(&self) -> &RepoSnapshot {
166 &self.state.snap
167 }
168
169 #[must_use]
171 pub fn selected_branch(&self) -> Option<&str> {
172 self.state.selected_branch.as_deref()
173 }
174
175 #[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#[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 #[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 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
258impl 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 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 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 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 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
354impl 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
374facett_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 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 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 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 #[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 #[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 #[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 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 #[test]
480 fn caps_advertised() {
481 let c = GitView::demo().caps();
482 assert!(c.scalable && c.selectable && c.themeable);
483 }
484
485 #[test]
489 fn harness_snapshot_drives_selection_and_pane() {
490 use facett_core::harness;
491 let mut v = GitView::demo();
492 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 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 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 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 #[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}