Skip to main content

xei_core/
git.rs

1//! Git gutter signs from `git diff` (working tree vs HEAD) + optional blame.
2
3use std::collections::HashMap;
4use std::path::Path;
5use std::process::Command;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum GitSign {
9    /// New line in working tree
10    Added,
11    /// Changed line
12    Modified,
13    /// Deletion adjacent to this line (show marker on the following line)
14    Deleted,
15}
16
17#[derive(Debug, Clone, Default)]
18pub struct BlameLine {
19    /// Short author (truncated)
20    pub author: String,
21    /// 7-char commit hash
22    pub hash: String,
23    /// Short date YYYY-MM-DD if known
24    pub date: String,
25}
26
27/// Full blame column width when open (cells).
28pub const BLAME_PANEL_WIDTH: u16 = 28;
29/// Slide-open / slide-close duration (ms).
30pub const BLAME_ANIM_MS: u64 = 300;
31
32#[derive(Debug, Clone)]
33pub struct GitBlame {
34    /// 0-based line → blame info
35    pub lines: HashMap<usize, BlameLine>,
36    pub path: String,
37    /// Panel open (or closing animation in progress).
38    pub open: bool,
39    pub available: bool,
40    /// Legacy inline mode (`gb` line suffix) — kept for optional use.
41    pub enabled: bool,
42    // ── open animation (SCM-style openness) ──
43    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    /// Whether the blame column should take layout space (open or animating).
80    pub fn visible(&self) -> bool {
81        self.open || self.closing
82    }
83
84    /// Linear openness 0..=1 for UI easing.
85    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    /// Open panel with slide-in (loads blame for `path`).
128    pub fn open_panel(&mut self, path: &str) -> String {
129        // Optimistic open — `git blame` runs on a background thread and the
130        // panel fills (or closes with a message) when the result lands.
131        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    /// Slide-out close.
152    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            // reopen mid-close
173            self.open_panel(path)
174        } else {
175            self.open_panel(path)
176        }
177    }
178
179    /// Legacy inline toggle (`gb`) — same panel for consistency.
180    pub fn toggle(&mut self, path: &str) -> String {
181        self.toggle_panel(path)
182    }
183
184    /// Sync wrapper (hot paths use App's async channel + `compute_blame`).
185    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
201/// Fixed **flame** palette — independent of editor theme.
202
203/// Blocking blame computation (runs on a background thread for the panel).
204pub 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    // Warm fire: deep red → orange → gold → ember
235    const FLAME: &[(u8, u8, u8)] = &[
236        (255, 48, 20),   // core red
237        (255, 90, 25),   // orange-red
238        (255, 130, 30),  // orange
239        (255, 170, 40),  // amber
240        (255, 200, 55),  // gold
241        (255, 110, 45),  // ember
242        (255, 70, 35),   // flame edge
243        (255, 150, 60),  // bright orange
244    ];
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
251/// Column width for current anim openness (eased in UI).
252pub 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
257/// Parse `git blame --line-porcelain` into per-line info.
258pub 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; // 0-based final line
263
264    for line in text.lines() {
265        if line.len() >= 40 && line.as_bytes().get(0).is_some_and(|b| b.is_ascii_hexdigit()) {
266            // header: hash orig final [group]
267            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            // unix timestamp → rough date via optional; keep raw short
280            if let Ok(secs) = t.parse::<i64>() {
281                // minimal YYYY without chrono: leave short stamp
282                date = format!("{secs}");
283                // Prefer author-mail skip; use time only if no better
284            }
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
309/// Blocking gutter computation (runs on a background thread).
310pub 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    /// 0-based buffer line → sign
344    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    /// Sync wrapper (hot paths use App's async channel + `compute_gutter`).
361    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
373/// Format blame for a narrow gutter: `ab  a1b2c3d`
374pub 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
379/// Parse unified diff hunks (`@@ -old,oc +new,nc @@`) into line signs.
380pub 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        // @@ -l,s +l,s @@
386        let Some(rest) = line.strip_prefix("@@") else {
387            continue;
388        };
389        let parts: Vec<&str> = rest.split_whitespace().collect();
390        // expect at least -old +new
391        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        // 1-based → 0-based for new file lines
406        if old_count == 0 && new_count > 0 {
407            // pure addition
408            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            // pure deletion — mark the line after the deletion point (or previous)
414            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            // modification / mix
422            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    // "10" or "10,3"
443    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}