1pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27enum Pane {
28 Tree,
29 Log,
30}
31
32pub struct GitView {
34 title: String,
35 snap: RepoSnapshot,
36 blob: Option<Blob>,
38 selected_branch: Option<String>,
39 selected_path: Option<String>,
40 pane: Pane,
41 scale: f32,
42}
43
44impl GitView {
45 #[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 #[must_use]
62 pub fn demo() -> Self {
63 Self::new(RepoSnapshot::demo())
64 }
65
66 #[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 pub fn set_blob(&mut self, blob: Blob) {
74 self.selected_path = Some(blob.path.clone());
75 self.blob = Some(blob);
76 }
77
78 #[must_use]
80 pub fn snapshot(&self) -> &RepoSnapshot {
81 &self.snap
82 }
83
84 #[must_use]
86 pub fn selected_branch(&self) -> Option<&str> {
87 self.selected_branch.as_deref()
88 }
89
90 #[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#[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 #[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 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
171impl 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 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 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 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 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 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 #[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 #[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 #[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 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 #[test]
341 fn caps_advertised() {
342 let c = GitView::demo().caps();
343 assert!(c.scalable && c.selectable && c.themeable);
344 }
345}