Skip to main content

xei_core/
scm.rs

1//! VS Code–style Source Control (SCM) panel state.
2//!
3//! Model mirrors VS Code's built-in Git provider:
4//! - resource groups: **Staged** (index) + **Changes** (working tree)
5//! - commit message input + Commit action
6//! - pretty commit graph via [`crate::git_graph`]
7
8use std::path::{Path, PathBuf};
9use std::process::Command;
10
11use crate::git_graph::{self, GraphRow};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum ScmStatus {
15    Modified,
16    Added,
17    Deleted,
18    Renamed,
19    Untracked,
20    Conflict,
21    TypeChange,
22    Unknown,
23}
24
25impl ScmStatus {
26    pub fn letter(self) -> char {
27        match self {
28            ScmStatus::Modified => 'M',
29            ScmStatus::Added => 'A',
30            ScmStatus::Deleted => 'D',
31            ScmStatus::Renamed => 'R',
32            ScmStatus::Untracked => 'U',
33            ScmStatus::Conflict => 'C',
34            ScmStatus::TypeChange => 'T',
35            ScmStatus::Unknown => '?',
36        }
37    }
38
39    pub fn label(self) -> &'static str {
40        match self {
41            ScmStatus::Modified => "Modified",
42            ScmStatus::Added => "Added",
43            ScmStatus::Deleted => "Deleted",
44            ScmStatus::Renamed => "Renamed",
45            ScmStatus::Untracked => "Untracked",
46            ScmStatus::Conflict => "Conflict",
47            ScmStatus::TypeChange => "Type change",
48            ScmStatus::Unknown => "Unknown",
49        }
50    }
51}
52
53#[derive(Debug, Clone)]
54pub struct ScmEntry {
55    pub path: String,
56    pub status: ScmStatus,
57    /// true = index/staged group
58    pub staged: bool,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum ScmFocus {
63    /// Typing the commit message
64    Message,
65    /// "✓ Commit" action row
66    CommitButton,
67    /// File list (staged + changes)
68    Changes,
69    /// Recent graph
70    Graph,
71}
72
73#[derive(Debug, Clone)]
74pub struct ScmPanel {
75    pub open: bool,
76    pub message: String,
77    pub focus: ScmFocus,
78    /// Selected index in the flattened changes list (staged first, then unstaged)
79    pub selected: usize,
80    pub staged: Vec<ScmEntry>,
81    pub changes: Vec<ScmEntry>,
82    pub branch: String,
83    pub ahead: u32,
84    pub behind: u32,
85    /// Pretty commit graph rows (newest first)
86    pub graph: Vec<GraphRow>,
87    /// Selected commit in the graph (when focus is Graph)
88    pub graph_selected: usize,
89    /// How many commits to request from `git log` (grows with load-more)
90    pub graph_limit: usize,
91    pub root: Option<PathBuf>,
92    pub error: Option<String>,
93    pub last_result: Option<String>,
94    /// Animation clock — armed on open/close, started by the first render so
95    /// synchronous `git status` / `git log` refresh can't eat the window.
96    pub opened_at: Option<std::time::Instant>,
97    pub anim_pending: bool,
98    /// Openness at phase start / end (0 = off-screen, 1 = fully visible).
99    pub anim_from: f32,
100    pub anim_to: f32,
101    /// True while a close animation is playing (`open` stays true until done).
102    pub closing: bool,
103    /// Set for one frame when the close animation settles (app clears mode).
104    pub just_closed: bool,
105    /// Defer expensive graph layout until first paint (open feels instant).
106    pub graph_pending: bool,
107}
108
109/// Slide animation length (ms) — open and close.
110pub const SCM_ANIM_MS: u64 = 320;
111
112/// Default commits for light SCM graph (keep small — workbench has full History).
113pub const GRAPH_DEFAULT_LIMIT: usize = 40;
114/// Upper cap so we don't hang on huge monorepos.
115pub const GRAPH_MAX_LIMIT: usize = 2000;
116
117impl Default for ScmPanel {
118    fn default() -> Self {
119        Self {
120            open: false,
121            message: String::new(),
122            focus: ScmFocus::Message,
123            selected: 0,
124            staged: Vec::new(),
125            changes: Vec::new(),
126            branch: String::new(),
127            ahead: 0,
128            behind: 0,
129            graph: Vec::new(),
130            graph_selected: 0,
131            graph_limit: GRAPH_DEFAULT_LIMIT,
132            root: None,
133            error: None,
134            last_result: None,
135            opened_at: None,
136            anim_pending: false,
137            anim_from: 0.0,
138            anim_to: 1.0,
139            closing: false,
140            just_closed: false,
141            graph_pending: false,
142        }
143    }
144}
145
146impl ScmPanel {
147    pub fn new() -> Self {
148        Self::default()
149    }
150
151    /// True while the panel should be painted (open or mid close-anim).
152    pub fn visible(&self) -> bool {
153        self.open
154    }
155
156    pub fn is_animating(&self) -> bool {
157        self.anim_pending
158            || self.closing
159            || self
160                .opened_at
161                .is_some_and(|t| t.elapsed().as_millis() < SCM_ANIM_MS as u128)
162    }
163
164    /// Instant hide (no animation) — used when switching to another overlay.
165    pub fn close_immediate(&mut self) {
166        self.open = false;
167        self.closing = false;
168        self.just_closed = false;
169        self.error = None;
170        self.opened_at = None;
171        self.anim_pending = false;
172        self.anim_from = 0.0;
173        self.anim_to = 0.0;
174    }
175
176    /// Start slide-out close. Keeps `open` until the animation finishes.
177    pub fn close(&mut self) {
178        if !self.open || self.closing {
179            return;
180        }
181        let current = self.snapshot_openness();
182        self.closing = true;
183        self.anim_from = current;
184        self.anim_to = 0.0;
185        self.anim_pending = true;
186        self.opened_at = None;
187    }
188
189    /// Open and refresh against repo containing `hint_path` (or cwd).
190    pub fn open_and_refresh(&mut self, hint_path: Option<&Path>) {
191        self.open = true;
192        self.closing = false;
193        self.focus = ScmFocus::Message;
194        // Arm open animation; clock starts at first rendered frame
195        // (refresh below shells out to git and can take a long time).
196        let from = if self.opened_at.is_some() {
197            self.snapshot_openness()
198        } else {
199            0.0
200        };
201        self.anim_from = from;
202        self.anim_to = 1.0;
203        self.anim_pending = true;
204        self.opened_at = None;
205        // Status only on open — graph loads on first draw (see ensure_graph).
206        self.refresh_status(hint_path);
207        self.graph_pending = true;
208        self.graph.clear();
209    }
210
211    /// Status/branch/changes only — no `git log` / graph layout.
212    pub fn refresh_status(&mut self, hint_path: Option<&Path>) {
213        self.error = None;
214        self.staged.clear();
215        self.changes.clear();
216        self.branch.clear();
217        self.ahead = 0;
218        self.behind = 0;
219
220        let root = find_git_root(hint_path);
221        self.root = root.clone();
222        let Some(root) = root else {
223            self.error = Some("Not a git repository".into());
224            return;
225        };
226
227        if let Some((branch, ahead, behind)) = parse_branch_status(&root) {
228            self.branch = branch;
229            self.ahead = ahead;
230            self.behind = behind;
231        }
232
233        match run_git(&root, &["status", "--porcelain=v1", "-uall"]) {
234            Ok(out) => self.refresh_entries_from_status(&out),
235            Err(e) => {
236                self.error = Some(e);
237                return;
238            }
239        }
240        self.clamp_selected();
241    }
242
243    /// Load graph if deferred (call from UI once per open).
244    pub fn ensure_graph(&mut self) {
245        if !self.graph_pending {
246            return;
247        }
248        self.graph_pending = false;
249        if let Some(ref root) = self.root.clone() {
250            self.reload_graph(root);
251        }
252    }
253
254    /// Linear **openness** 0.0..=1.0 (0 = off-screen, 1 = fully shown).
255    /// Easing is applied in the UI. First call after arming starts the clock.
256    pub fn anim_progress(&mut self) -> f32 {
257        let v = self.tick_openness();
258        if self.closing && v <= 0.001 {
259            self.finish_close();
260        }
261        v
262    }
263
264    fn snapshot_openness(&self) -> f32 {
265        if self.anim_pending {
266            return self.anim_from;
267        }
268        let Some(t0) = self.opened_at else {
269            return if self.open && !self.closing {
270                1.0
271            } else {
272                0.0
273            };
274        };
275        let u = (t0.elapsed().as_millis() as f32 / SCM_ANIM_MS as f32).min(1.0);
276        self.anim_from + (self.anim_to - self.anim_from) * u
277    }
278
279    fn tick_openness(&mut self) -> f32 {
280        if self.anim_pending {
281            self.anim_pending = false;
282            self.opened_at = Some(std::time::Instant::now());
283            return self.anim_from;
284        }
285        let Some(t0) = self.opened_at else {
286            return if self.open && !self.closing {
287                1.0
288            } else {
289                0.0
290            };
291        };
292        let u = (t0.elapsed().as_millis() as f32 / SCM_ANIM_MS as f32).min(1.0);
293        self.anim_from + (self.anim_to - self.anim_from) * u
294    }
295
296    fn finish_close(&mut self) {
297        self.open = false;
298        self.closing = false;
299        self.just_closed = true;
300        self.error = None;
301        self.opened_at = None;
302        self.anim_pending = false;
303        self.anim_from = 0.0;
304        self.anim_to = 0.0;
305    }
306
307    /// Returns true once when a close animation has settled.
308    pub fn take_just_closed(&mut self) -> bool {
309        if self.just_closed {
310            self.just_closed = false;
311            true
312        } else {
313            false
314        }
315    }
316
317    pub fn total_files(&self) -> usize {
318        self.staged.len() + self.changes.len()
319    }
320
321    pub fn entry_at(&self, idx: usize) -> Option<&ScmEntry> {
322        if idx < self.staged.len() {
323            self.staged.get(idx)
324        } else {
325            self.changes.get(idx - self.staged.len())
326        }
327    }
328
329    pub fn clamp_selected(&mut self) {
330        let n = self.total_files();
331        if n == 0 {
332            self.selected = 0;
333        } else if self.selected >= n {
334            self.selected = n - 1;
335        }
336    }
337
338    pub fn move_sel(&mut self, delta: isize) {
339        let n = self.total_files();
340        if n == 0 {
341            self.selected = 0;
342            return;
343        }
344        let cur = self.selected as isize + delta;
345        self.selected = cur.clamp(0, (n - 1) as isize) as usize;
346    }
347
348    pub fn refresh(&mut self, hint_path: Option<&Path>) {
349        self.refresh_status(hint_path);
350        // Only rebuild graph when the panel is open (status-bar refresh skips it).
351        if self.open {
352            if let Some(ref root) = self.root.clone() {
353                self.reload_graph(root);
354            }
355            self.graph_pending = false;
356        } else {
357            self.graph.clear();
358            self.graph_pending = false;
359        }
360    }
361
362    fn reload_graph(&mut self, root: &Path) {
363        // Pretty graph: topology + decorations + author/time
364        // Use --all so gc-reachable tips on other branches still appear.
365        // Avoid packing issues: plain `log` walks the commit graph (not only reflog).
366        let limit = self.graph_limit.clamp(50, GRAPH_MAX_LIMIT).to_string();
367        if let Ok(out) = run_git(
368            root,
369            &[
370                "log",
371                "--all",
372                "--date-order",
373                "-n",
374                &limit,
375                "--pretty=format:%H%x00%h%x00%P%x00%d%x00%s%x00%an%x00%ar",
376            ],
377        ) {
378            self.graph = git_graph::build_graph(&out);
379            if self.graph_selected >= self.graph.len() {
380                self.graph_selected = self.graph.len().saturating_sub(1);
381            }
382        }
383    }
384
385    /// Fetch more history (double limit, capped). Call while graph is focused.
386    pub fn load_more_graph(&mut self) -> Result<usize, String> {
387        let root = self.root.clone().ok_or_else(|| "No git root".to_string())?;
388        let prev = self.graph.len();
389        let next = (self.graph_limit.saturating_mul(2)).min(GRAPH_MAX_LIMIT);
390        if next <= self.graph_limit && self.graph_limit >= GRAPH_MAX_LIMIT {
391            return Ok(prev);
392        }
393        self.graph_limit = next.max(self.graph_limit + 100);
394        self.reload_graph(&root);
395        Ok(self.graph.len().saturating_sub(prev))
396    }
397
398    pub fn move_graph_sel(&mut self, delta: isize) {
399        let n = self.graph.len();
400        if n == 0 {
401            self.graph_selected = 0;
402            return;
403        }
404        let cur = self.graph_selected as isize + delta;
405        self.graph_selected = cur.clamp(0, (n - 1) as isize) as usize;
406        // Near the bottom → auto load more history
407        if self.graph_selected + 5 >= n && self.graph_limit < GRAPH_MAX_LIMIT {
408            let _ = self.load_more_graph();
409        }
410    }
411
412    pub fn selected_graph_row(&self) -> Option<&GraphRow> {
413        self.graph.get(self.graph_selected)
414    }
415
416    /// Stage the selected file (or all unstaged if none selected / with `all`).
417    pub fn stage_selected(&mut self) -> Result<(), String> {
418        let root = self.root.clone().ok_or_else(|| "No git root".to_string())?;
419        if let Some(e) = self.entry_at(self.selected).cloned() {
420            if e.staged {
421                // already staged — unstage
422                run_git(&root, &["restore", "--staged", "--", &e.path])?;
423            } else {
424                run_git(&root, &["add", "--", &e.path])?;
425            }
426        }
427        self.refresh(Some(&root));
428        Ok(())
429    }
430
431    pub fn stage_all(&mut self) -> Result<(), String> {
432        let root = self.root.clone().ok_or_else(|| "No git root".to_string())?;
433        run_git(&root, &["add", "-A"])?;
434        self.refresh(Some(&root));
435        self.last_result = Some("Staged all changes".into());
436        Ok(())
437    }
438
439    pub fn unstage_all(&mut self) -> Result<(), String> {
440        let root = self.root.clone().ok_or_else(|| "No git root".to_string())?;
441        run_git(&root, &["restore", "--staged", "."])?;
442        self.refresh(Some(&root));
443        self.last_result = Some("Unstaged all".into());
444        Ok(())
445    }
446
447    pub fn discard_selected(&mut self) -> Result<(), String> {
448        let root = self.root.clone().ok_or_else(|| "No git root".to_string())?;
449        let e = self
450            .entry_at(self.selected)
451            .cloned()
452            .ok_or_else(|| "No file selected".to_string())?;
453        if e.staged {
454            run_git(&root, &["restore", "--staged", "--", &e.path])?;
455        }
456        match e.status {
457            ScmStatus::Untracked | ScmStatus::Added => {
458                // remove untracked carefully
459                let p = root.join(&e.path);
460                if p.is_file() {
461                    std::fs::remove_file(&p).map_err(|err| err.to_string())?;
462                }
463            }
464            _ => {
465                run_git(&root, &["restore", "--", &e.path])?;
466            }
467        }
468        self.refresh(Some(&root));
469        self.last_result = Some(format!("Discarded {}", e.path));
470        Ok(())
471    }
472
473    /// Commit staged changes with current message. If nothing staged, stage all first (VS Code default often requires staged; we match VS Code "Commit" on staged only unless message + commit all shortcut).
474    pub fn commit(&mut self, amend: bool) -> Result<(), String> {
475        let root = self.root.clone().ok_or_else(|| "No git root".to_string())?;
476        let msg = self.message.trim().to_string();
477        if msg.is_empty() && !amend {
478            return Err("Commit message is empty".into());
479        }
480        if self.staged.is_empty() && !amend {
481            // VS Code: commit only staged.
482            if self.changes.is_empty() {
483                return Err("No changes to commit".into());
484            }
485            return Err("No staged changes — press `a` to stage all, or Space on a file".into());
486        }
487        let out = if amend {
488            if msg.is_empty() {
489                run_git(&root, &["commit", "--amend", "--no-edit"])?
490            } else {
491                run_git(&root, &["commit", "--amend", "-m", &msg])?
492            }
493        } else {
494            run_git(&root, &["commit", "-m", &msg])?
495        };
496        self.message.clear();
497        self.refresh(Some(&root));
498        let summary = out.lines().next().unwrap_or("Committed").to_string();
499        self.last_result = Some(summary);
500        Ok(())
501    }
502
503    pub fn cycle_focus(&mut self, forward: bool) {
504        self.focus = if forward {
505            match self.focus {
506                ScmFocus::Message => ScmFocus::CommitButton,
507                ScmFocus::CommitButton => ScmFocus::Changes,
508                ScmFocus::Changes => ScmFocus::Graph,
509                ScmFocus::Graph => ScmFocus::Message,
510            }
511        } else {
512            match self.focus {
513                ScmFocus::Message => ScmFocus::Graph,
514                ScmFocus::CommitButton => ScmFocus::Message,
515                ScmFocus::Changes => ScmFocus::CommitButton,
516                ScmFocus::Graph => ScmFocus::Changes,
517            }
518        };
519    }
520}
521
522fn find_git_root(hint: Option<&Path>) -> Option<PathBuf> {
523    let start = hint
524        .and_then(|p| {
525            if p.is_file() {
526                p.parent().map(|x| x.to_path_buf())
527            } else {
528                Some(p.to_path_buf())
529            }
530        })
531        .or_else(|| std::env::current_dir().ok())?;
532
533    let mut cur = start;
534    for _ in 0..24 {
535        if cur.join(".git").exists() {
536            return Some(cur);
537        }
538        if !cur.pop() {
539            break;
540        }
541    }
542    None
543}
544
545fn run_git(root: &Path, args: &[&str]) -> Result<String, String> {
546    let output = Command::new("git")
547        .args(args)
548        .current_dir(root)
549        .output()
550        .map_err(|e| format!("git failed to start: {e}"))?;
551    if !output.status.success() {
552        let err = String::from_utf8_lossy(&output.stderr);
553        let err = err.trim();
554        if err.is_empty() {
555            return Err(format!("git {} failed", args.first().unwrap_or(&"")));
556        }
557        return Err(err.lines().next().unwrap_or("git error").to_string());
558    }
559    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
560}
561
562fn parse_branch_status(root: &Path) -> Option<(String, u32, u32)> {
563    // ## main...origin/main [ahead 1, behind 2]
564    let out = run_git(root, &["status", "-sb"]).ok()?;
565    let first = out.lines().next()?;
566    let rest = first.strip_prefix("## ")?;
567    let branch = rest
568        .split(['.', ' ', '['])
569        .next()
570        .unwrap_or(rest)
571        .to_string();
572    let mut ahead = 0u32;
573    let mut behind = 0u32;
574    if let Some(idx) = rest.find('[') {
575        let bracket = &rest[idx..];
576        if let Some(a) = bracket.split("ahead ").nth(1) {
577            ahead = a
578                .chars()
579                .take_while(|c| c.is_ascii_digit())
580                .collect::<String>()
581                .parse()
582                .unwrap_or(0);
583        }
584        if let Some(b) = bracket.split("behind ").nth(1) {
585            behind = b
586                .chars()
587                .take_while(|c| c.is_ascii_digit())
588                .collect::<String>()
589                .parse()
590                .unwrap_or(0);
591        }
592    }
593    Some((branch, ahead, behind))
594}
595
596fn status_from_code(c: char) -> ScmStatus {
597    match c {
598        'M' => ScmStatus::Modified,
599        'A' => ScmStatus::Added,
600        'D' => ScmStatus::Deleted,
601        'R' => ScmStatus::Renamed,
602        'C' => ScmStatus::Conflict, // also copy — treat as conflict-ish
603        'U' => ScmStatus::Conflict,
604        'T' => ScmStatus::TypeChange,
605        '?' => ScmStatus::Untracked,
606        _ => ScmStatus::Unknown,
607    }
608}
609
610/// Parse porcelain line into 0–2 entries (staged and/or unstaged).
611pub fn parse_porcelain_entries(line: &str) -> Vec<ScmEntry> {
612    let mut out = Vec::new();
613    if line.len() < 4 {
614        return out;
615    }
616    let bytes = line.as_bytes();
617    let x = bytes[0] as char;
618    let y = bytes[1] as char;
619    let path_part = match line.get(3..) {
620        Some(p) => p.trim(),
621        None => return out,
622    };
623    if path_part.is_empty() {
624        return out;
625    }
626    let path = if let Some((_, new)) = path_part.split_once(" -> ") {
627        new.to_string()
628    } else {
629        path_part.to_string()
630    };
631
632    if x == '?' && y == '?' {
633        out.push(ScmEntry {
634            path,
635            status: ScmStatus::Untracked,
636            staged: false,
637        });
638        return out;
639    }
640    if x == '!' {
641        return out;
642    }
643
644    // Unmerged
645    if matches!(x, 'U' | 'A' | 'D') && matches!(y, 'U' | 'A' | 'D') && (x == 'U' || y == 'U') {
646        out.push(ScmEntry {
647            path,
648            status: ScmStatus::Conflict,
649            staged: false,
650        });
651        return out;
652    }
653
654    if x != ' ' && x != '?' {
655        out.push(ScmEntry {
656            path: path.clone(),
657            status: status_from_code(x),
658            staged: true,
659        });
660    }
661    if y != ' ' && y != '?' {
662        out.push(ScmEntry {
663            path,
664            status: status_from_code(y),
665            staged: false,
666        });
667    }
668    out
669}
670
671impl ScmPanel {
672    pub fn refresh_entries_from_status(&mut self, porcelain: &str) {
673        self.staged.clear();
674        self.changes.clear();
675        for line in porcelain.lines() {
676            for e in parse_porcelain_entries(line) {
677                if e.staged {
678                    self.staged.push(e);
679                } else {
680                    self.changes.push(e);
681                }
682            }
683        }
684    }
685}
686
687#[cfg(test)]
688mod tests {
689    use super::*;
690
691    #[test]
692    fn porcelain_modified_worktree() {
693        let e = parse_porcelain_entries(" M src/main.rs");
694        assert_eq!(e.len(), 1);
695        assert!(!e[0].staged);
696        assert_eq!(e[0].status, ScmStatus::Modified);
697        assert_eq!(e[0].path, "src/main.rs");
698    }
699
700    #[test]
701    fn porcelain_staged_and_unstaged() {
702        let e = parse_porcelain_entries("MM app.rs");
703        assert_eq!(e.len(), 2);
704        assert!(e[0].staged);
705        assert!(!e[1].staged);
706    }
707
708    #[test]
709    fn porcelain_untracked() {
710        let e = parse_porcelain_entries("?? new.txt");
711        assert_eq!(e.len(), 1);
712        assert_eq!(e[0].status, ScmStatus::Untracked);
713    }
714
715    #[test]
716    fn porcelain_renamed() {
717        let e = parse_porcelain_entries("R  old.rs -> new.rs");
718        assert_eq!(e.len(), 1);
719        assert!(e[0].staged);
720        assert_eq!(e[0].path, "new.rs");
721        assert_eq!(e[0].status, ScmStatus::Renamed);
722    }
723
724    #[test]
725    fn status_letters() {
726        assert_eq!(ScmStatus::Modified.letter(), 'M');
727        assert_eq!(ScmStatus::Untracked.letter(), 'U');
728    }
729}