use crate::{
error::{Error, Result},
modes::interactive::theme::{Theme, color},
utils::wheel::KeyRepeatAccelerator,
};
use ratatui::{layout::Rect, style::Color, text::Line};
use regex::Regex;
use std::{collections::HashMap, process::Command, sync::LazyLock};
pub type LineChange = (usize, String);
pub type FileChanges = HashMap<String, (Vec<LineChange>, Vec<LineChange>)>;
static DIFF_FILE_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^diff --git a/(.+) b/(.+)$").unwrap());
static HUNK_HEADER_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^@@ -(\d+),?\d* \+(\d+),?\d* @@").unwrap());
static ANSI_ESCAPE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\x1b\[.*?m").unwrap());
pub fn collect_uncommitted() -> Result<(FileChanges, String, String)> {
let output = git_diff()?;
Ok((
parse(&output),
"HEAD".to_string(),
"Working Tree".to_string(),
))
}
fn git_diff() -> Result<String> {
let output = Command::new("git")
.args(["diff", "--no-color"])
.output()
.map_err(|e| Error::Message(format!("failed to run git: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Error::Message(format!(
"git diff failed: {}",
stderr.trim()
)));
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
pub fn parse(diff_output: &str) -> FileChanges {
let mut file_changes = FileChanges::new();
let mut current_file = String::new();
let mut base_lines: Vec<LineChange> = Vec::new();
let mut head_lines: Vec<LineChange> = Vec::new();
let mut base_line_number = 1usize;
let mut head_line_number = 1usize;
for raw_line in diff_output.lines() {
let trimmed = raw_line.trim_end();
if trimmed.starts_with("\\ ") {
continue;
}
let line = ANSI_ESCAPE_RE.replace_all(trimmed, "");
if let Some(caps) = DIFF_FILE_RE.captures(line.as_ref()) {
if !current_file.is_empty() {
file_changes.insert(
std::mem::take(&mut current_file),
(
std::mem::take(&mut base_lines),
std::mem::take(&mut head_lines),
),
);
}
current_file = match caps.get(2) {
Some(m) => m.as_str().to_string(),
None => continue,
};
base_line_number = 1;
head_line_number = 1;
continue;
}
if let Some(caps) = HUNK_HEADER_RE.captures(line.as_ref()) {
base_line_number = caps
.get(1)
.and_then(|m| m.as_str().parse::<usize>().ok())
.unwrap_or(1);
head_line_number = caps
.get(2)
.and_then(|m| m.as_str().parse::<usize>().ok())
.unwrap_or(1);
continue;
}
if is_metadata(line.as_ref()) {
continue;
}
if let Some(rest) = line.strip_prefix('-') {
base_lines.push((base_line_number, format!("-{rest}")));
base_line_number += 1;
} else if let Some(rest) = line.strip_prefix('+') {
head_lines.push((head_line_number, format!("+{rest}")));
head_line_number += 1;
} else {
base_lines.push((base_line_number, line.to_string()));
head_lines.push((head_line_number, line.to_string()));
base_line_number += 1;
head_line_number += 1;
}
}
if !current_file.is_empty() {
file_changes.insert(current_file, (base_lines, head_lines));
}
file_changes
}
fn is_metadata(line: &str) -> bool {
[
"index",
"---",
"+++",
"@@",
"new file mode",
"new mode",
"old mode",
"deleted file mode",
"rename from",
"rename to",
"copy from",
"copy to",
"similarity index",
"dissimilarity index",
"Binary files",
]
.iter()
.any(|prefix| line.starts_with(prefix))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Pane {
FileList,
DiffContent,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ViewMode {
SideBySide,
Unified,
}
pub struct GiffStyle {
pub accent: Color,
pub border_focused: Color,
pub border_dim: Color,
pub fg_normal: Color,
pub fg_dim: Color,
pub fg_bright: Color,
pub fg_added: Color,
pub fg_removed: Color,
pub fg_key: Color,
pub fg_line_num: Color,
pub fg_added_marker: Color,
pub fg_removed_marker: Color,
pub bg_header: Color,
pub bg_selection: Color,
pub bg_added: Color,
pub bg_removed: Color,
pub bg_modal: Color,
pub bg_modal_dim: Color,
pub border_modal: Color,
pub bg_key_badge: Color,
pub fg_separator: Color,
pub fg_badge: Color,
}
impl GiffStyle {
pub fn from_theme(theme: &Theme) -> Self {
let c = |key: &str, fallback: &str| {
let value = theme.get(key, fallback);
if value.is_empty() {
Color::Reset
} else {
color(&value)
}
};
Self {
accent: c("accent", "violet"),
border_focused: c("borderAccent", "#8abeb7"),
border_dim: c("borderMuted", "#666666"),
fg_normal: c("text", ""),
fg_dim: c("dim", "#666666"),
fg_bright: c("text", ""),
fg_added: c("toolDiffAdded", "green"),
fg_removed: c("toolDiffRemoved", "red"),
fg_key: c("accent", "violet"),
fg_line_num: c("dim", "#666666"),
fg_added_marker: c("toolDiffAdded", "green"),
fg_removed_marker: c("toolDiffRemoved", "red"),
bg_header: c("selectedBg", "#2a2a3c"),
bg_selection: c("selectedBg", "#2a2a3c"),
bg_added: c("toolSuccessBg", "#1e3a2a"),
bg_removed: c("toolErrorBg", "#3a1e1e"),
bg_modal: c("customMessageBg", "#2a1e3a"),
bg_modal_dim: Color::Reset,
border_modal: c("borderAccent", "violet"),
bg_key_badge: c("selectedBg", "#3a3a5c"),
fg_separator: c("borderMuted", "#666666"),
fg_badge: c("text", ""),
}
}
}
pub(super) struct GiffContentCache {
pub(super) file: String,
pub(super) view_mode: ViewMode,
pub(super) theme_fp: String,
pub(super) left: Vec<Line<'static>>,
pub(super) right: Vec<Line<'static>>,
pub(super) content_len: usize,
}
#[derive(Debug, Clone, Default)]
pub struct GiffRestore {
file: Option<String>,
scroll_positions: HashMap<String, usize>,
focused_pane: Option<Pane>,
view_mode: Option<ViewMode>,
}
pub struct GiffView {
pub(super) file_changes: FileChanges,
pub(super) left_label: String,
pub(super) right_label: String,
pub(super) file_names: Vec<String>,
pub(super) current_file_idx: usize,
pub(super) scroll_positions: HashMap<String, usize>,
pub(super) focused_pane: Pane,
pub(super) view_mode: ViewMode,
pub(super) show_help: bool,
pub input_active: bool,
pub(super) style: GiffStyle,
pub(super) syntect_theme: Option<syntect::highlighting::Theme>,
pub(super) syntect_fp: String,
pub(super) page_rows: u16,
pub(super) file_list_area: Option<Rect>,
pub(super) content_area: Option<Rect>,
pub(super) content_cache: Option<GiffContentCache>,
pub(super) key_repeat: KeyRepeatAccelerator,
}
impl GiffView {
pub fn new(
file_changes: FileChanges,
left_label: String,
right_label: String,
theme: &Theme,
restore: &GiffRestore,
) -> Self {
let mut file_names: Vec<String> = file_changes.keys().cloned().collect();
file_names.sort();
let current_file_idx = restore
.file
.as_ref()
.and_then(|file| file_names.iter().position(|name| name == file))
.unwrap_or(0);
let scroll_positions = restore
.scroll_positions
.iter()
.filter(|(file, _)| file_names.contains(file))
.map(|(file, offset)| (file.clone(), *offset))
.collect();
Self {
file_changes,
left_label,
right_label,
file_names,
current_file_idx,
scroll_positions,
focused_pane: restore.focused_pane.unwrap_or(Pane::FileList),
view_mode: restore.view_mode.unwrap_or(ViewMode::SideBySide),
show_help: false,
input_active: false,
style: GiffStyle::from_theme(theme),
syntect_theme: None,
syntect_fp: String::new(),
page_rows: 1,
file_list_area: None,
content_area: None,
content_cache: None,
key_repeat: KeyRepeatAccelerator::new(),
}
}
pub fn into_restore(self) -> GiffRestore {
GiffRestore {
file: self.file_names.get(self.current_file_idx).cloned(),
scroll_positions: self.scroll_positions,
focused_pane: Some(self.focused_pane),
view_mode: Some(self.view_mode),
}
}
pub fn current_file(&self) -> Option<&str> {
self.file_names
.get(self.current_file_idx)
.map(String::as_str)
}
pub fn file_count(&self) -> usize {
self.file_names.len()
}
pub fn move_file(&mut self, delta: isize) {
if self.file_names.is_empty() {
return;
}
let max = self.file_names.len() as isize - 1;
self.current_file_idx = (self.current_file_idx as isize + delta).clamp(0, max) as usize;
}
pub fn select_edge(&mut self, last: bool) {
if self.file_names.is_empty() {
return;
}
self.current_file_idx = if last { self.file_names.len() - 1 } else { 0 };
}
pub fn scroll_by(&mut self, delta: isize) {
let Some(file) = self.current_file().map(str::to_string) else {
return;
};
let current = self.scroll_positions.get(&file).copied().unwrap_or(0);
let next = if delta == isize::MAX {
usize::MAX
} else if delta == isize::MIN {
0
} else {
current.saturating_add_signed(delta)
};
self.scroll_positions.insert(file, next);
}
pub fn page(&mut self, forward: bool) {
let step = self.page_rows.max(1) as isize;
self.scroll_by(if forward { step } else { -step });
}
pub fn nav_step(&mut self, direction: i32, now_ms: u64) -> isize {
self.key_repeat.next(direction, now_ms) as isize
}
pub fn reset_nav_repeat(&mut self) {
self.key_repeat.reset();
}
pub fn scroll_to_top(&mut self) {
self.scroll_by(isize::MIN);
}
pub fn scroll_to_bottom(&mut self) {
self.scroll_by(isize::MAX);
}
pub fn toggle_focus(&mut self) {
self.focused_pane = match self.focused_pane {
Pane::FileList => Pane::DiffContent,
Pane::DiffContent => Pane::FileList,
};
}
pub fn focus(&mut self, pane: Pane) {
self.focused_pane = pane;
}
pub fn toggle_view(&mut self) {
self.view_mode = match self.view_mode {
ViewMode::SideBySide => ViewMode::Unified,
ViewMode::Unified => ViewMode::SideBySide,
};
}
pub fn toggle_help(&mut self) {
self.show_help = !self.show_help;
}
pub fn is_input_active(&self) -> bool {
self.input_active
}
pub fn set_input_active(&mut self, active: bool) {
self.input_active = active;
}
pub fn is_help_open(&self) -> bool {
self.show_help
}
pub fn close_help(&mut self) {
self.show_help = false;
}
pub fn focused_pane(&self) -> Pane {
self.focused_pane
}
pub fn file_list_area(&self) -> Option<Rect> {
self.file_list_area
}
pub fn page_rows(&self) -> usize {
self.page_rows.max(1) as usize
}
pub fn scroll_positions(&self) -> &HashMap<String, usize> {
&self.scroll_positions
}
pub fn view_mode(&self) -> ViewMode {
self.view_mode
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::modes::interactive::theme::Theme;
use std::collections::HashMap;
const SAMPLE: &str = "\
diff --git a/src/a.rs b/src/a.rs
index 1111111..2222222 100644
--- a/src/a.rs
+++ b/src/a.rs
@@ -1,4 +1,4 @@
fn main() {
- println!(\"old\");
+ println!(\"new\");
// keep
}
diff --git a/src/b.rs b/src/b.rs
new file mode 100644
index 0000000..3333333
--- /dev/null
+++ b/src/b.rs
@@ -0,0 +1,2 @@
+line one
+line two
";
fn view() -> GiffView {
GiffView::new(
parse(SAMPLE),
"HEAD".to_string(),
"Working Tree".to_string(),
&Theme::default(),
&GiffRestore::default(),
)
}
#[test]
fn parse_splits_files_and_context() {
let changes = parse(SAMPLE);
assert_eq!(changes.len(), 2);
let (base, head) = &changes["src/a.rs"];
assert_eq!(base.len(), 4, "上下文 2 + 删除 1 + 上下文 1");
assert_eq!(head.len(), 4);
assert_eq!(base[0], (1, " fn main() {".to_string()));
assert_eq!(base[1], (2, "- println!(\"old\");".to_string()));
assert_eq!(head[1], (2, "+ println!(\"new\");".to_string()));
assert_eq!(base[3].0, 4, "删除行之后的行号继续推进");
let (base, head) = &changes["src/b.rs"];
assert!(base.is_empty(), "新增文件没有左侧内容");
assert_eq!(head.len(), 2);
assert_eq!(head[0], (1, "+line one".to_string()));
}
#[test]
fn hunk_header_resets_line_numbers() {
let diff = "\
diff --git a/f.rs b/f.rs
--- a/f.rs
+++ b/f.rs
@@ -1,1 +1,1 @@
-a
+b
@@ -10,1 +10,1 @@
-c
+d
";
let (base, head) = &parse(diff)["f.rs"];
assert_eq!(base[0], (1, "-a".to_string()));
assert_eq!(base[1], (10, "-c".to_string()));
assert_eq!(head[1], (10, "+d".to_string()));
}
#[test]
fn metadata_and_no_newline_marker_are_skipped() {
let diff = "\
diff --git a/f.rs b/f.rs
old mode 100644
new mode 100755
rename from f.rs
rename to g.rs
Binary files a/x.bin and b/x.bin differ
diff --git a/h.rs b/h.rs
--- a/h.rs
+++ b/h.rs
@@ -1,1 +1,1 @@
-a
\\ No newline at end of file
+b
\\ No newline at end of file
";
let changes = parse(diff);
let (base, head) = &changes["f.rs"];
assert!(
base.is_empty() && head.is_empty(),
"纯元数据的文件两侧都没有内容"
);
let (base, head) = &changes["h.rs"];
assert_eq!(base.len(), 1);
assert_eq!(head.len(), 1);
}
#[test]
fn empty_input_yields_no_changes() {
assert!(parse("").is_empty());
}
#[test]
fn restore_reselects_last_file_and_scroll() {
let restore = GiffRestore {
file: Some("src/b.rs".to_string()),
scroll_positions: HashMap::from([("src/b.rs".to_string(), 3)]),
..GiffRestore::default()
};
let view = GiffView::new(
parse(SAMPLE),
"HEAD".to_string(),
"Working Tree".to_string(),
&Theme::default(),
&restore,
);
assert_eq!(view.current_file(), Some("src/b.rs"));
assert_eq!(view.scroll_positions.get("src/b.rs"), Some(&3));
}
#[test]
fn restore_falls_back_when_file_gone() {
let restore = GiffRestore {
file: Some("gone.rs".to_string()),
scroll_positions: HashMap::from([("gone.rs".to_string(), 7)]),
view_mode: Some(ViewMode::Unified),
..GiffRestore::default()
};
let view = GiffView::new(
parse(SAMPLE),
"HEAD".to_string(),
"Working Tree".to_string(),
&Theme::default(),
&restore,
);
assert_eq!(view.current_file(), Some("src/a.rs"));
assert!(
view.scroll_positions.is_empty(),
"已消失文件的滚动位置被丢弃"
);
assert_eq!(view.view_mode(), ViewMode::Unified, "视图模式仍恢复");
}
#[test]
fn into_restore_captures_state() {
let mut view = view();
view.move_file(1);
view.scroll_by(5);
view.toggle_focus();
view.toggle_view();
let restore = view.into_restore();
let reopened = GiffView::new(
parse(SAMPLE),
"HEAD".to_string(),
"Working Tree".to_string(),
&Theme::default(),
&restore,
);
assert_eq!(reopened.current_file(), Some("src/b.rs"));
assert_eq!(reopened.scroll_positions.get("src/b.rs"), Some(&5));
assert_eq!(reopened.focused_pane(), Pane::DiffContent);
assert_eq!(reopened.view_mode(), ViewMode::Unified);
}
#[test]
fn nav_step_accelerates_while_held() {
let mut view = view();
assert_eq!(view.nav_step(1, 0), 1, "孤立一次按 1 行");
assert_eq!(view.nav_step(1, 33), 3, "按住不放加速");
view.reset_nav_repeat();
assert_eq!(view.nav_step(1, 40), 1, "复位后重新从 1 行起算");
}
#[test]
fn file_navigation_clamps() {
let mut view = view();
view.move_file(-1);
assert_eq!(view.current_file(), Some("src/a.rs"));
view.move_file(10);
assert_eq!(view.current_file(), Some("src/b.rs"));
view.select_edge(false);
assert_eq!(view.current_file(), Some("src/a.rs"));
}
}