1use std::collections::HashMap;
4use std::path::Path;
5use std::process::Command;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum GitSign {
9 Added,
11 Modified,
13 Deleted,
15}
16
17#[derive(Debug, Clone, Default)]
18pub struct BlameLine {
19 pub author: String,
21 pub hash: String,
23 pub date: String,
25}
26
27pub const BLAME_PANEL_WIDTH: u16 = 28;
29pub const BLAME_ANIM_MS: u64 = 300;
31
32#[derive(Debug, Clone)]
33pub struct GitBlame {
34 pub lines: HashMap<usize, BlameLine>,
36 pub path: String,
37 pub open: bool,
39 pub available: bool,
40 pub enabled: bool,
42 pub closing: bool,
44 pub anim_from: f32,
45 pub anim_to: f32,
46 pub anim_pending: bool,
47 pub opened_at: Option<std::time::Instant>,
48}
49
50impl Default for GitBlame {
51 fn default() -> Self {
52 Self {
53 lines: HashMap::new(),
54 path: String::new(),
55 open: false,
56 available: false,
57 enabled: false,
58 closing: false,
59 anim_from: 0.0,
60 anim_to: 0.0,
61 anim_pending: false,
62 opened_at: None,
63 }
64 }
65}
66
67impl GitBlame {
68 pub fn clear(&mut self) {
69 self.lines.clear();
70 self.path.clear();
71 self.available = false;
72 self.enabled = false;
73 self.open = false;
74 self.closing = false;
75 self.opened_at = None;
76 self.anim_pending = false;
77 }
78
79 pub fn visible(&self) -> bool {
81 self.open || self.closing
82 }
83
84 pub fn anim_progress(&mut self) -> f32 {
86 let v = self.tick_openness();
87 if self.closing && v <= 0.001 {
88 self.finish_close();
89 }
90 v
91 }
92
93 fn snapshot_openness(&self) -> f32 {
94 if self.anim_pending {
95 return self.anim_from;
96 }
97 let Some(t0) = self.opened_at else {
98 return if self.open && !self.closing {
99 1.0
100 } else {
101 0.0
102 };
103 };
104 let u = (t0.elapsed().as_millis() as f32 / BLAME_ANIM_MS as f32).min(1.0);
105 self.anim_from + (self.anim_to - self.anim_from) * u
106 }
107
108 fn tick_openness(&mut self) -> f32 {
109 if self.anim_pending {
110 self.anim_pending = false;
111 self.opened_at = Some(std::time::Instant::now());
112 return self.anim_from;
113 }
114 self.snapshot_openness()
115 }
116
117 fn finish_close(&mut self) {
118 self.open = false;
119 self.closing = false;
120 self.enabled = false;
121 self.opened_at = None;
122 self.anim_pending = false;
123 self.anim_from = 0.0;
124 self.anim_to = 0.0;
125 }
126
127 pub fn open_panel(&mut self, path: &str) -> String {
129 self.path = path.to_string();
132 let from = if self.open || self.closing {
133 self.snapshot_openness()
134 } else {
135 0.0
136 };
137 self.open = true;
138 self.closing = false;
139 self.enabled = true;
140 self.anim_from = from;
141 self.anim_to = 1.0;
142 self.anim_pending = true;
143 self.opened_at = None;
144 if self.lines.is_empty() {
145 "Blame · loading… · Ctrl+B close".into()
146 } else {
147 format!("Blame · {} lines · Ctrl+B close", self.lines.len())
148 }
149 }
150
151 pub fn close_panel(&mut self) {
153 if !self.open || self.closing {
154 if !self.open {
155 self.enabled = false;
156 }
157 return;
158 }
159 let cur = self.snapshot_openness();
160 self.closing = true;
161 self.anim_from = cur;
162 self.anim_to = 0.0;
163 self.anim_pending = true;
164 self.opened_at = None;
165 }
166
167 pub fn toggle_panel(&mut self, path: &str) -> String {
168 if self.open && !self.closing {
169 self.close_panel();
170 "Blame closing…".into()
171 } else if self.closing {
172 self.open_panel(path)
174 } else {
175 self.open_panel(path)
176 }
177 }
178
179 pub fn toggle(&mut self, path: &str) -> String {
181 self.toggle_panel(path)
182 }
183
184 pub fn refresh(&mut self, path: &str) {
186 let (available, lines) = compute_blame(path);
187 self.path = path.to_string();
188 self.available = available;
189 self.lines = lines;
190 }
191
192 pub fn at(&self, row: usize) -> Option<&BlameLine> {
193 if self.enabled || self.open {
194 self.lines.get(&row)
195 } else {
196 None
197 }
198 }
199}
200
201pub fn compute_blame(path: &str) -> (bool, HashMap<usize, BlameLine>) {
205 let mut lines = HashMap::new();
206 if path.is_empty() {
207 return (false, lines);
208 }
209 let abs = std::fs::canonicalize(path).unwrap_or_else(|_| Path::new(path).to_path_buf());
210 let Some(parent) = abs.parent() else {
211 return (false, lines);
212 };
213 let output = Command::new("git")
214 .args([
215 "blame",
216 "--line-porcelain",
217 "--",
218 abs.to_str().unwrap_or(path),
219 ])
220 .current_dir(parent)
221 .output();
222 let Ok(out) = output else {
223 return (false, lines);
224 };
225 if !out.status.success() {
226 return (false, lines);
227 }
228 let text = String::from_utf8_lossy(&out.stdout);
229 parse_blame_porcelain(&text, &mut lines);
230 (!lines.is_empty(), lines)
231}
232
233pub fn flame_color_for(key: &str) -> (u8, u8, u8) {
234 const FLAME: &[(u8, u8, u8)] = &[
236 (255, 48, 20), (255, 90, 25), (255, 130, 30), (255, 170, 40), (255, 200, 55), (255, 110, 45), (255, 70, 35), (255, 150, 60), ];
245 let h = key
246 .bytes()
247 .fold(0u32, |acc, b| acc.wrapping_mul(33).wrapping_add(b as u32));
248 FLAME[(h as usize) % FLAME.len()]
249}
250
251pub fn blame_width_for_openness(t: f32) -> u16 {
253 let t = t.clamp(0.0, 1.0);
254 ((BLAME_PANEL_WIDTH as f32) * t).round() as u16
255}
256
257pub fn parse_blame_porcelain(text: &str, out: &mut HashMap<usize, BlameLine>) {
259 let mut hash = String::new();
260 let mut author = String::new();
261 let mut date = String::new();
262 let mut line_no: Option<usize> = None; for line in text.lines() {
265 if line.len() >= 40 && line.as_bytes().get(0).is_some_and(|b| b.is_ascii_hexdigit()) {
266 let parts: Vec<&str> = line.split_whitespace().collect();
268 if parts.len() >= 3 {
269 hash = parts[0].chars().take(7).collect();
270 if let Ok(n) = parts[2].parse::<usize>() {
271 line_no = Some(n.saturating_sub(1));
272 }
273 }
274 author.clear();
275 date.clear();
276 } else if let Some(a) = line.strip_prefix("author ") {
277 author = a.chars().take(12).collect();
278 } else if let Some(t) = line.strip_prefix("author-time ") {
279 if let Ok(secs) = t.parse::<i64>() {
281 date = format!("{secs}");
283 }
285 } else if let Some(d) = line.strip_prefix("author-time ") {
286 let _ = d;
287 } else if line.starts_with('\t') {
288 if let Some(row) = line_no {
289 let auth = if author.is_empty() {
290 "?".into()
291 } else {
292 author.clone()
293 };
294 out.insert(
295 row,
296 BlameLine {
297 author: auth,
298 hash: hash.clone(),
299 date: date.clone(),
300 },
301 );
302 }
303 line_no = None;
304 }
305 }
306}
307
308
309pub fn compute_gutter(path: &str) -> (bool, HashMap<usize, GitSign>) {
311 let mut signs = HashMap::new();
312 if path.is_empty() {
313 return (false, signs);
314 }
315 let abs = std::fs::canonicalize(path).unwrap_or_else(|_| Path::new(path).to_path_buf());
316 let Some(parent) = abs.parent() else {
317 return (false, signs);
318 };
319 let output = Command::new("git")
320 .args([
321 "diff",
322 "HEAD",
323 "--no-color",
324 "-U0",
325 "--",
326 abs.to_str().unwrap_or(path),
327 ])
328 .current_dir(parent)
329 .output();
330 let Ok(out) = output else {
331 return (false, signs);
332 };
333 if !out.status.success() && out.stdout.is_empty() {
334 return (false, signs);
335 }
336 let text = String::from_utf8_lossy(&out.stdout);
337 parse_diff_hunks(&text, &mut signs);
338 (true, signs)
339}
340
341#[derive(Debug, Default, Clone)]
342pub struct GitGutter {
343 pub signs: HashMap<usize, GitSign>,
345 pub path: String,
346 pub available: bool,
347}
348
349impl GitGutter {
350 pub fn new() -> Self {
351 Self::default()
352 }
353
354 pub fn clear(&mut self) {
355 self.signs.clear();
356 self.path.clear();
357 self.available = false;
358 }
359
360 pub fn refresh(&mut self, path: &str) {
362 let (available, signs) = compute_gutter(path);
363 self.path = path.to_string();
364 self.available = available;
365 self.signs = signs;
366 }
367
368 pub fn sign_at(&self, row: usize) -> Option<GitSign> {
369 self.signs.get(&row).copied()
370 }
371}
372
373pub fn format_blame_gutter(b: &BlameLine, width: usize) -> String {
375 let s = format!("{:<8} {}", b.author.chars().take(8).collect::<String>(), b.hash);
376 s.chars().take(width).collect()
377}
378
379pub fn parse_diff_hunks(diff: &str, signs: &mut HashMap<usize, GitSign>) {
381 for line in diff.lines() {
382 if !line.starts_with("@@") {
383 continue;
384 }
385 let Some(rest) = line.strip_prefix("@@") else {
387 continue;
388 };
389 let parts: Vec<&str> = rest.split_whitespace().collect();
390 let mut old_count = 1i64;
392 let mut new_start = 0i64;
393 let mut new_count = 1i64;
394 for p in parts {
395 if let Some(spec) = p.strip_prefix('-') {
396 let (_s, c) = parse_hunk_spec(spec);
397 old_count = c;
398 } else if let Some(spec) = p.strip_prefix('+') {
399 let (s, c) = parse_hunk_spec(spec);
400 new_start = s;
401 new_count = c;
402 }
403 }
404
405 if old_count == 0 && new_count > 0 {
407 for i in 0..new_count as usize {
409 let row = (new_start - 1).max(0) as usize + i;
410 signs.insert(row, GitSign::Added);
411 }
412 } else if new_count == 0 && old_count > 0 {
413 let row = if new_start > 0 {
415 (new_start as usize).saturating_sub(1)
416 } else {
417 0
418 };
419 signs.entry(row).or_insert(GitSign::Deleted);
420 } else {
421 let n = new_count.max(0) as usize;
423 let o = old_count.max(0) as usize;
424 let base = (new_start - 1).max(0) as usize;
425 for i in 0..n {
426 let row = base + i;
427 if i < o {
428 signs.insert(row, GitSign::Modified);
429 } else {
430 signs.insert(row, GitSign::Added);
431 }
432 }
433 if o > n {
434 signs.entry(base.saturating_add(n.saturating_sub(1)).max(0))
435 .or_insert(GitSign::Deleted);
436 }
437 }
438 }
439}
440
441fn parse_hunk_spec(spec: &str) -> (i64, i64) {
442 if let Some((a, b)) = spec.split_once(',') {
444 let s = a.parse().unwrap_or(0);
445 let c = b.parse().unwrap_or(1);
446 (s, c)
447 } else {
448 let s = spec.parse().unwrap_or(0);
449 (s, 1)
450 }
451}
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456
457 #[test]
458 fn parse_added_lines() {
459 let mut m = HashMap::new();
460 parse_diff_hunks("@@ -5,0 +6,2 @@\n+a\n+b\n", &mut m);
461 assert_eq!(m.get(&5), Some(&GitSign::Added));
462 assert_eq!(m.get(&6), Some(&GitSign::Added));
463 }
464
465 #[test]
466 fn parse_modified_line() {
467 let mut m = HashMap::new();
468 parse_diff_hunks("@@ -10,1 +10,1 @@\n-old\n+new\n", &mut m);
469 assert_eq!(m.get(&9), Some(&GitSign::Modified));
470 }
471
472 #[test]
473 fn parse_deleted() {
474 let mut m = HashMap::new();
475 parse_diff_hunks("@@ -3,2 +3,0 @@\n-a\n-b\n", &mut m);
476 assert!(m.values().any(|s| *s == GitSign::Deleted));
477 }
478}