1use 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 pub staged: bool,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum ScmFocus {
63 Message,
65 CommitButton,
67 Changes,
69 Graph,
71}
72
73#[derive(Debug, Clone)]
74pub struct ScmPanel {
75 pub open: bool,
76 pub message: String,
77 pub focus: ScmFocus,
78 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 pub graph: Vec<GraphRow>,
87 pub graph_selected: usize,
89 pub graph_limit: usize,
91 pub root: Option<PathBuf>,
92 pub error: Option<String>,
93 pub last_result: Option<String>,
94 pub opened_at: Option<std::time::Instant>,
97 pub anim_pending: bool,
98 pub anim_from: f32,
100 pub anim_to: f32,
101 pub closing: bool,
103 pub just_closed: bool,
105 pub graph_pending: bool,
107}
108
109pub const SCM_ANIM_MS: u64 = 320;
111
112pub const GRAPH_DEFAULT_LIMIT: usize = 40;
114pub 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 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 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 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 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 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 self.refresh_status(hint_path);
207 self.graph_pending = true;
208 self.graph.clear();
209 }
210
211 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 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 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 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 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 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 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 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 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 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 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 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 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 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, 'U' => ScmStatus::Conflict,
604 'T' => ScmStatus::TypeChange,
605 '?' => ScmStatus::Untracked,
606 _ => ScmStatus::Unknown,
607 }
608}
609
610pub 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 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}