use crate::comments::model::CommentStore;
use crate::diff::model::{Changeset, DiffFile, LineKind, Side};
use crate::ui::highlight::Highlighter;
use crate::ui::render_rows::{
build_rows, build_split_rows, CommentLine, Row, RowKind, SideCell, SplitRow, SplitRowKind,
};
use crate::ui::theme::THEME;
use anyhow::Result;
use crossterm::event::{
self, Event, KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
use ratatui::prelude::*;
use ratatui::widgets::{
Block, BorderType, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState,
};
use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::time::{Duration, SystemTime};
type LineRuns = Rc<Vec<(Color, String)>>;
type HlKey = (usize, String);
pub struct WatchPaths {
pub patch: Option<PathBuf>,
pub comments: Option<PathBuf>,
}
struct Watch {
patch: Option<PathBuf>,
comments: Option<PathBuf>,
patch_mtime: Option<SystemTime>,
comments_mtime: Option<SystemTime>,
}
fn file_mtime(p: &Path) -> Option<SystemTime> {
std::fs::metadata(p).and_then(|m| m.modified()).ok()
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum View {
Unified,
Split,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Focus {
Sidebar,
Diff,
}
enum SbRow {
Dir {
path: String,
name: String,
depth: usize,
},
File {
idx: usize,
depth: usize,
},
}
fn file_status(f: &DiffFile) -> (char, Color) {
let added = f.old_path == "/dev/null" || f.old_path.is_empty();
let deleted = f.new_path == "/dev/null" || f.new_path.is_empty();
if added {
('A', THEME.added)
} else if deleted {
('D', THEME.removed)
} else if f.old_path != f.new_path {
('R', THEME.accent)
} else {
('M', THEME.warn)
}
}
fn dir_of(path: &str) -> &str {
match path.rfind('/') {
Some(i) => &path[..i],
None => "",
}
}
fn base_of(path: &str) -> &str {
path.rsplit('/').next().unwrap_or(path)
}
fn build_sidebar_rows(
changeset: &Changeset,
collapsed: &HashSet<String>,
) -> (Vec<SbRow>, Vec<usize>) {
let mut rows = Vec::new();
let mut map = vec![usize::MAX; changeset.files.len()];
let mut prev: Vec<String> = Vec::new();
for (i, f) in changeset.files.iter().enumerate() {
let dir = dir_of(f.display_path());
let segs: Vec<String> = if dir.is_empty() {
Vec::new()
} else {
dir.split('/').map(|s| s.to_string()).collect()
};
let mut common = 0;
while common < segs.len() && common < prev.len() && segs[common] == prev[common] {
common += 1;
}
for d in common..segs.len() {
let ancestor_collapsed = (0..d).any(|a| collapsed.contains(&segs[..=a].join("/")));
if ancestor_collapsed {
continue;
}
rows.push(SbRow::Dir {
path: segs[..=d].join("/"),
name: segs[d].clone(),
depth: d,
});
}
prev = segs.clone();
let hidden = (0..segs.len()).any(|d| collapsed.contains(&segs[..=d].join("/")));
if hidden {
continue;
}
map[i] = rows.len();
rows.push(SbRow::File {
idx: i,
depth: segs.len(),
});
}
(rows, map)
}
fn file_comment_state(comments: &CommentStore, path: &str) -> Option<bool> {
let p = Path::new(path);
let mut any = false;
let mut open = false;
for t in comments.threads.iter().filter(|t| t.file == p) {
any = true;
if !t.resolved {
open = true;
}
}
any.then_some(open)
}
const SIDEBAR_WIDTH: u16 = 38;
const MIN_SIDEBAR: u16 = 14;
const MIN_DIFF: u16 = 20;
fn file_stats(changeset: &Changeset) -> Vec<(usize, usize)> {
changeset
.files
.iter()
.map(|f| {
let mut adds = 0;
let mut dels = 0;
for h in &f.hunks {
for l in &h.lines {
match l.kind {
LineKind::Addition => adds += 1,
LineKind::Deletion => dels += 1,
LineKind::Context => {}
}
}
}
(adds, dels)
})
.collect()
}
fn base64(data: &[u8]) -> String {
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
for chunk in data.chunks(3) {
let b0 = chunk[0] as u32;
let b1 = *chunk.get(1).unwrap_or(&0) as u32;
let b2 = *chunk.get(2).unwrap_or(&0) as u32;
let n = (b0 << 16) | (b1 << 8) | b2;
out.push(T[(n >> 18 & 63) as usize] as char);
out.push(T[(n >> 12 & 63) as usize] as char);
out.push(if chunk.len() > 1 {
T[(n >> 6 & 63) as usize] as char
} else {
'='
});
out.push(if chunk.len() > 2 {
T[(n & 63) as usize] as char
} else {
'='
});
}
out
}
fn hit(rect: Rect, col: u16, row: u16) -> bool {
col >= rect.x && col < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height
}
fn sb_thumb_pos(track_y: u16, track_h: usize, total: usize, viewport: usize, row: u16) -> usize {
let max_top = total.saturating_sub(viewport);
if max_top == 0 || track_h == 0 {
return 0;
}
let thumb = ((viewport as f32) * (track_h as f32) / (total as f32))
.round()
.max(1.0) as usize;
let span = track_h.saturating_sub(thumb).max(1);
let off = (row.saturating_sub(track_y) as usize).min(span);
((off as f32 / span as f32) * max_top as f32).round() as usize
}
fn elide_left(s: &str, w: usize) -> String {
let n = s.chars().count();
if n <= w {
return s.to_string();
}
if w <= 1 {
return "…".chars().take(w).collect();
}
let tail: String = s.chars().skip(n - (w - 1)).collect();
format!("…{tail}")
}
pub struct App {
changeset: Changeset,
rows: Vec<Row>,
comments: CommentStore,
split_rows: Vec<SplitRow>,
view: View,
selected: usize, scroll: usize, height: usize, status: String,
watch: Option<Watch>,
needs_clear: bool,
show_sidebar: bool,
sidebar_width: u16,
sidebar_scroll: usize, sidebar_sel: usize, expanded: HashSet<usize>, collapsed: HashSet<String>, comment_wrap: usize, resizing: bool, focus: Focus,
current_file: usize, sidebar_rows: Vec<SbRow>, file_to_sbrow: Vec<usize>, sel_anchor: Option<usize>, pending_copy: Option<String>, file_stats: Vec<(usize, usize)>,
diff_area: Rect, sidebar_area: Rect, diff_sb: Rect, sidebar_sb: Rect, sb_drag: Option<Focus>, highlighter: Highlighter,
hl_cache: RefCell<HashMap<HlKey, LineRuns>>,
quit: bool,
}
impl App {
pub fn with_comments(changeset: Changeset, comments: CommentStore) -> Self {
let expanded: HashSet<usize> = (0..comments.threads.len()).collect();
let rows = build_rows(&changeset, &comments, &expanded, 0);
let split_rows = build_split_rows(&changeset, &comments, &expanded, 0);
let stats = file_stats(&changeset);
let collapsed = HashSet::new();
let (sidebar_rows, file_to_sbrow) = build_sidebar_rows(&changeset, &collapsed);
let mut app = App {
changeset,
rows,
split_rows,
view: View::Split,
comments,
selected: 0,
scroll: 0,
height: 1,
status: "q/^C/^D quit j/k move enter focus → esc focus ← ←/→ fold tab split"
.into(),
watch: None,
needs_clear: false,
show_sidebar: true,
sidebar_width: SIDEBAR_WIDTH,
sidebar_scroll: 0,
sidebar_sel: file_to_sbrow
.iter()
.copied()
.find(|&r| r != usize::MAX)
.unwrap_or(0),
expanded,
collapsed,
comment_wrap: 0,
resizing: false,
focus: Focus::Sidebar,
current_file: 0,
sidebar_rows,
file_to_sbrow,
sel_anchor: None,
pending_copy: None,
file_stats: stats,
diff_area: Rect::default(),
sidebar_area: Rect::default(),
diff_sb: Rect::default(),
sidebar_sb: Rect::default(),
sb_drag: None,
highlighter: Highlighter::new(),
hl_cache: RefCell::new(HashMap::new()),
quit: false,
};
app.selected = app.first_selectable().unwrap_or(0);
app
}
fn highlight(&self, file_idx: usize, text: &str) -> LineRuns {
let key = (file_idx, text.to_string());
if let Some(v) = self.hl_cache.borrow().get(&key) {
return v.clone();
}
let spans = match self.changeset.files.get(file_idx) {
Some(f) => {
let syntax = self.highlighter.syntax_for(f.display_path());
self.highlighter.line(syntax, text)
}
None => vec![(THEME.text, text.to_string())],
};
let rc = Rc::new(spans);
self.hl_cache.borrow_mut().insert(key, rc.clone());
rc
}
fn styled_fit(
&self,
file_idx: usize,
text: &str,
width: usize,
bg: Option<Color>,
) -> Vec<Span<'static>> {
let hl = self.highlight(file_idx, text);
let mut out = Vec::new();
let mut used = 0usize;
for (c, s) in hl.iter() {
if used >= width {
break;
}
let take: String = s.chars().take(width - used).collect();
if take.is_empty() {
continue;
}
used += take.chars().count();
let mut st = Style::default().fg(*c);
if let Some(b) = bg {
st = st.bg(b);
}
out.push(Span::styled(take, st));
}
if used < width {
let mut st = Style::default();
if let Some(b) = bg {
st = st.bg(b);
}
out.push(Span::styled(" ".repeat(width - used), st));
}
out
}
pub fn watching(mut self, paths: WatchPaths) -> Self {
self.watch = Some(Watch {
patch_mtime: paths.patch.as_deref().and_then(file_mtime),
comments_mtime: paths.comments.as_deref().and_then(file_mtime),
patch: paths.patch,
comments: paths.comments,
});
self
}
fn first_selectable(&self) -> Option<usize> {
let (s, e) = self.file_range();
(s..e).find(|&i| self.is_selectable_at(i))
}
fn last_selectable(&self) -> Option<usize> {
let (s, e) = self.file_range();
(s..e).rev().find(|&i| self.is_selectable_at(i))
}
pub fn run(&mut self, terminal: &mut Terminal<impl Backend>) -> Result<()> {
while !self.quit {
if self.needs_clear {
terminal.clear()?;
self.needs_clear = false;
}
terminal.draw(|f| self.draw(f))?;
if event::poll(Duration::from_millis(200))? {
loop {
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => {
self.on_key(key.code, key.modifiers);
}
Event::Mouse(me) => self.on_mouse(me),
_ => {}
}
if !event::poll(Duration::from_millis(0))? {
break;
}
}
}
if let Some(text) = self.pending_copy.take() {
use std::io::Write;
let seq = format!("\x1b]52;c;{}\x07", base64(text.as_bytes()));
let mut out = std::io::stdout();
let _ = out.write_all(seq.as_bytes());
let _ = out.flush();
}
self.poll_reload();
}
Ok(())
}
fn divider_col(&self) -> Option<u16> {
(self.sidebar_area.width > 0).then(|| self.sidebar_area.x + self.sidebar_area.width)
}
fn resize_to(&mut self, col: u16) {
let total = self.sidebar_area.width + self.diff_area.width;
let max = total.saturating_sub(MIN_DIFF).max(MIN_SIDEBAR);
self.sidebar_width = col
.saturating_sub(self.sidebar_area.x)
.clamp(MIN_SIDEBAR, max);
}
fn on_mouse(&mut self, me: MouseEvent) {
let (col, row) = (me.column, me.row);
let on_divider = self.divider_col() == Some(col);
let over_sidebar = self.sidebar_area.width > 0 && hit(self.sidebar_area, col, row);
match me.kind {
MouseEventKind::Up(_) => {
self.resizing = false;
self.sb_drag = None;
}
MouseEventKind::Down(MouseButton::Left) if hit(self.diff_sb, col, row) => {
self.sb_drag = Some(Focus::Diff);
self.drag_diff_sb(row);
}
MouseEventKind::Down(MouseButton::Left) if hit(self.sidebar_sb, col, row) => {
self.sb_drag = Some(Focus::Sidebar);
self.drag_sidebar_sb(row);
}
MouseEventKind::Down(MouseButton::Left) if on_divider => self.resizing = true,
MouseEventKind::Drag(MouseButton::Left) if self.sb_drag == Some(Focus::Diff) => {
self.drag_diff_sb(row)
}
MouseEventKind::Drag(MouseButton::Left) if self.sb_drag == Some(Focus::Sidebar) => {
self.drag_sidebar_sb(row)
}
MouseEventKind::Drag(MouseButton::Left) if self.resizing => self.resize_to(col),
MouseEventKind::ScrollDown => {
if over_sidebar {
self.scroll_sidebar(3);
} else {
self.focus = Focus::Diff;
self.scroll_view(3);
}
}
MouseEventKind::ScrollUp => {
if over_sidebar {
self.scroll_sidebar(-3);
} else {
self.focus = Focus::Diff;
self.scroll_view(-3);
}
}
MouseEventKind::Down(MouseButton::Left) => {
if over_sidebar {
self.click_sidebar(row);
} else if hit(self.diff_area, col, row) {
self.click_diff(row, true);
}
}
MouseEventKind::Drag(MouseButton::Left) if hit(self.diff_area, col, row) => {
self.click_diff(row, false);
}
_ => {}
}
}
fn in_selection(&self, idx: usize) -> bool {
let anchor = self.sel_anchor.unwrap_or(self.selected);
let (lo, hi) = (anchor.min(self.selected), anchor.max(self.selected));
idx >= lo && idx <= hi
}
fn line_text(&self, idx: usize) -> Option<String> {
match self.view {
View::Unified => match self.rows.get(idx)?.kind {
RowKind::Line { .. } => {
let t = &self.rows[idx].text;
Some(t.get(1..).unwrap_or("").to_string())
}
_ => None,
},
View::Split => match &self.split_rows.get(idx)?.kind {
SplitRowKind::Pair { left, right } => {
right.as_ref().or(left.as_ref()).map(|c| c.text.clone())
}
_ => None,
},
}
}
fn copy_selection(&mut self) {
let anchor = self.sel_anchor.unwrap_or(self.selected);
let (lo, hi) = (anchor.min(self.selected), anchor.max(self.selected));
let lines: Vec<String> = (lo..=hi).filter_map(|i| self.line_text(i)).collect();
if lines.is_empty() {
return;
}
self.status = format!("copied {} line(s)", lines.len());
self.pending_copy = Some(lines.join("\n"));
}
fn drag_diff_sb(&mut self, row: u16) {
let (start, end) = self.file_range();
let total = end - start;
let pos = sb_thumb_pos(
self.diff_sb.y,
self.diff_sb.height as usize,
total,
self.height,
row,
);
self.scroll = start + pos;
}
fn drag_sidebar_sb(&mut self, row: u16) {
let h = self.sidebar_sb.height as usize;
self.sidebar_scroll = sb_thumb_pos(self.sidebar_sb.y, h, self.sidebar_rows.len(), h, row);
}
fn scroll_sidebar(&mut self, delta: isize) {
let h = self.sidebar_area.height as usize;
let max = self.sidebar_rows.len().saturating_sub(h);
self.sidebar_scroll =
(self.sidebar_scroll as isize + delta).clamp(0, max as isize) as usize;
}
fn reveal_sidebar(&mut self) {
let h = self.sidebar_area.height as usize;
if h == 0 {
return;
}
let r = self
.sidebar_sel
.min(self.sidebar_rows.len().saturating_sub(1));
let target = r.saturating_sub(1);
if target < self.sidebar_scroll {
self.sidebar_scroll = target;
} else if r >= self.sidebar_scroll + h {
self.sidebar_scroll = r + 1 - h;
}
}
fn sb_selectable(&self, idx: usize) -> bool {
idx < self.sidebar_rows.len()
}
fn rebuild_sidebar(&mut self) {
let (sr, map) = build_sidebar_rows(&self.changeset, &self.collapsed);
self.sidebar_rows = sr;
self.file_to_sbrow = map;
let h = self.sidebar_area.height as usize;
let max = self.sidebar_rows.len().saturating_sub(h);
self.sidebar_scroll = self.sidebar_scroll.min(max);
}
fn reveal_file_in_tree(&mut self, fi: usize) {
let Some(f) = self.changeset.files.get(fi) else {
return;
};
let dir = dir_of(f.display_path());
if dir.is_empty() {
return;
}
let segs: Vec<&str> = dir.split('/').collect();
let mut changed = false;
for d in 0..segs.len() {
if self.collapsed.remove(&segs[..=d].join("/")) {
changed = true;
}
}
if changed {
self.rebuild_sidebar();
}
}
fn set_dir_collapsed(&mut self, path: String, collapsed: bool) {
let changed = if collapsed {
self.collapsed.insert(path.clone())
} else {
self.collapsed.remove(&path)
};
if !changed {
return;
}
self.rebuild_sidebar();
if let Some(r) = self
.sidebar_rows
.iter()
.position(|row| matches!(row, SbRow::Dir { path: p, .. } if *p == path))
{
self.sidebar_sel = r;
}
self.sidebar_sel = self
.sidebar_sel
.min(self.sidebar_rows.len().saturating_sub(1));
self.reveal_sidebar();
}
fn toggle_dir(&mut self, path: String) {
let collapsed = self.collapsed.contains(&path);
self.set_dir_collapsed(path, !collapsed);
}
fn fold_dir(&mut self, collapse: bool) {
match self.sidebar_rows.get(self.sidebar_sel) {
Some(SbRow::Dir { path, .. }) => {
let path = path.clone();
if collapse && self.collapsed.contains(&path) {
let parent = dir_of(&path);
if !parent.is_empty() {
self.set_dir_collapsed(parent.to_string(), true);
}
} else {
self.set_dir_collapsed(path, collapse);
}
}
Some(SbRow::File { idx, .. }) if collapse => {
let fi = *idx;
if let Some(parent) = self.parent_dir_of_file(fi) {
self.set_dir_collapsed(parent, true);
}
}
_ => {}
}
}
fn parent_dir_of_file(&self, fi: usize) -> Option<String> {
let f = self.changeset.files.get(fi)?;
let dir = dir_of(f.display_path());
(!dir.is_empty()).then(|| dir.to_string())
}
fn fold_dir_toggle(&mut self) {
if let Some(SbRow::Dir { path, .. }) = self.sidebar_rows.get(self.sidebar_sel) {
let path = path.clone();
self.toggle_dir(path);
}
}
fn move_sidebar(&mut self, dir: isize) {
let n = self.sidebar_rows.len();
let mut i = self.sidebar_sel as isize;
loop {
i += dir;
if i < 0 || i as usize >= n {
return;
}
if self.sb_selectable(i as usize) {
self.sidebar_sel = i as usize;
self.activate_sidebar();
return;
}
}
}
fn sidebar_edge(&mut self, last: bool) {
let n = self.sidebar_rows.len();
let found = if last {
(0..n).rev().find(|&i| self.sb_selectable(i))
} else {
(0..n).find(|&i| self.sb_selectable(i))
};
if let Some(i) = found {
self.sidebar_sel = i;
self.activate_sidebar();
}
}
fn activate_sidebar(&mut self) {
if let Some(SbRow::File { idx, .. }) = self.sidebar_rows.get(self.sidebar_sel) {
let fi = *idx;
if fi != self.current_file {
self.set_current_file(fi);
}
self.reveal_sidebar();
}
}
fn click_sidebar(&mut self, row: u16) {
let off = row.saturating_sub(self.sidebar_area.y) as usize;
let h = self.sidebar_area.height as usize;
let max = self.sidebar_rows.len().saturating_sub(h);
let scroll = self.sidebar_scroll.min(max);
let idx = scroll + off;
match self.sidebar_rows.get(idx) {
Some(SbRow::Dir { path, .. }) => {
let path = path.clone();
self.focus = Focus::Sidebar;
self.sidebar_sel = idx;
self.toggle_dir(path);
}
Some(SbRow::File { idx: fi, .. }) => {
let fi = *fi;
self.focus = Focus::Sidebar;
self.set_current_file(fi);
}
None => {}
}
}
fn rebuild_rows(&mut self) {
let anchor = self.anchor_at(self.selected);
let cur_file = self.current_file;
self.rows = build_rows(
&self.changeset,
&self.comments,
&self.expanded,
self.comment_wrap,
);
self.split_rows = build_split_rows(
&self.changeset,
&self.comments,
&self.expanded,
self.comment_wrap,
);
let target = anchor.and_then(|a| {
(0..self.active_len())
.find(|&i| self.is_selectable_at(i) && self.anchor_at(i) == Some(a))
});
self.selected = target
.or_else(|| self.first_selectable())
.unwrap_or(0)
.min(self.active_len().saturating_sub(1));
self.current_file = self.row_file_idx(self.selected).unwrap_or(cur_file);
self.ensure_visible();
}
fn toggle_comment(&mut self) {
let Some((fi, side, line)) = self.anchor_at(self.selected) else {
return;
};
let Some(file) = self.changeset.files.get(fi) else {
return;
};
let path = Path::new(file.display_path());
let here: Vec<usize> = self
.comments
.threads
.iter()
.enumerate()
.filter(|(_, t)| t.file.as_path() == path && t.side == side && t.range.contains(line))
.map(|(i, _)| i)
.collect();
if here.is_empty() {
self.status = "no comments on this line".into();
return;
}
if here.iter().all(|i| self.expanded.contains(i)) {
for i in &here {
self.expanded.remove(i);
}
self.status = "collapsed thread".into();
} else {
for i in here {
self.expanded.insert(i);
}
self.status = "expanded thread".into();
}
self.rebuild_rows();
}
fn click_diff(&mut self, row: u16, anchor: bool) {
self.focus = Focus::Diff;
let (start, end) = self.file_range();
let top = self.scroll.max(start);
let idx = (top + row.saturating_sub(self.diff_area.y) as usize)
.clamp(start, end.saturating_sub(1).max(start));
let target = if self.is_selectable_at(idx) {
Some(idx)
} else {
self.nearest_selectable(idx, 1)
.or_else(|| self.nearest_selectable(idx, -1))
};
if let Some(i) = target {
self.selected = i;
if anchor {
self.sel_anchor = Some(i);
}
self.ensure_visible();
}
}
fn poll_reload(&mut self) {
let changed = match self.watch.as_mut() {
None => false,
Some(w) => {
let mut changed = false;
if let Some(p) = &w.patch {
let m = file_mtime(p);
if m != w.patch_mtime {
w.patch_mtime = m;
changed = true;
}
}
if let Some(p) = &w.comments {
let m = file_mtime(p);
if m != w.comments_mtime {
w.comments_mtime = m;
changed = true;
}
}
changed
}
};
if changed {
self.reload();
}
}
fn reload(&mut self) {
let (patch, comments) = match &self.watch {
Some(w) => (w.patch.clone(), w.comments.clone()),
None => return,
};
if let Some(p) = patch {
match crate::loader::load_patch(Some(&p)) {
Ok(cs) => {
self.changeset = cs;
self.file_stats = file_stats(&self.changeset);
self.hl_cache.borrow_mut().clear();
}
Err(e) => {
self.status = format!("reload failed: {e}");
return;
}
}
}
if let Some(p) = comments {
match crate::loader::load_comments(&p) {
Ok(store) => self.comments = store,
Err(e) => self.status = format!("comments reload failed: {e}"),
}
}
self.rebuild_sidebar();
self.expanded = (0..self.comments.threads.len()).collect();
self.rebuild_rows();
self.set_current_file(self.current_file);
self.status = "reloaded".into();
}
fn active_len(&self) -> usize {
match self.view {
View::Unified => self.rows.len(),
View::Split => self.split_rows.len(),
}
}
fn is_selectable_at(&self, i: usize) -> bool {
match self.view {
View::Unified => self.rows.get(i).is_some_and(|r| r.is_selectable()),
View::Split => self.split_rows.get(i).is_some_and(|r| r.is_selectable()),
}
}
fn row_file_idx(&self, i: usize) -> Option<usize> {
match self.view {
View::Unified => self.rows.get(i).map(|r| r.file_idx),
View::Split => self.split_rows.get(i).map(|r| r.file_idx),
}
}
fn file_range(&self) -> (usize, usize) {
let len = self.active_len();
let (mut start, mut end) = (len, len);
for i in 0..len {
if self.row_file_idx(i) == Some(self.current_file) {
if start == len {
start = i;
}
end = i + 1;
}
}
(start, end)
}
fn jump_file(&mut self, dir: isize) {
let n = self.changeset.files.len();
if n == 0 {
return;
}
let target = (self.current_file as isize + dir).clamp(0, n as isize - 1) as usize;
if target == self.current_file {
return;
}
self.set_current_file(target);
}
fn set_current_file(&mut self, file: usize) {
self.sel_anchor = None;
self.current_file = file.min(self.changeset.files.len().saturating_sub(1));
self.reveal_file_in_tree(self.current_file);
self.sidebar_sel = self
.file_to_sbrow
.get(self.current_file)
.copied()
.filter(|&r| r != usize::MAX)
.unwrap_or(0);
self.reveal_sidebar();
let (start, _) = self.file_range();
self.scroll = start;
self.selected = self.first_selectable().unwrap_or(start);
self.ensure_visible();
}
fn anchor_at(&self, i: usize) -> Option<(usize, Side, u32)> {
match self.view {
View::Unified => {
let r = self.rows.get(i)?;
let (s, l) = r.anchor()?;
Some((r.file_idx, s, l))
}
View::Split => {
let r = self.split_rows.get(i)?;
let (s, l) = r.anchor()?;
Some((r.file_idx, s, l))
}
}
}
fn toggle_view(&mut self) {
self.sel_anchor = None;
let anchor = self.anchor_at(self.selected);
self.view = match self.view {
View::Unified => View::Split,
View::Split => View::Unified,
};
let target = anchor.and_then(|a| {
(0..self.active_len())
.find(|&i| self.is_selectable_at(i) && self.anchor_at(i) == Some(a))
});
self.selected = target
.or_else(|| self.first_selectable())
.unwrap_or(0)
.min(self.active_len().saturating_sub(1));
self.current_file = self
.row_file_idx(self.selected)
.unwrap_or(self.current_file);
self.scroll = self.selected.saturating_sub(self.height / 2);
self.ensure_visible();
self.status = match self.view {
View::Unified => "unified view".into(),
View::Split => "split view".into(),
};
}
fn sidebar_available(&self) -> bool {
self.show_sidebar && self.changeset.files.len() > 1
}
fn effective_focus(&self) -> Focus {
if self.sidebar_available() {
self.focus
} else {
Focus::Diff
}
}
fn diff_cursor_bg(&self) -> Color {
if self.effective_focus() == Focus::Diff {
THEME.cursor_bg
} else {
THEME.unfocus_bg
}
}
fn on_key(&mut self, code: KeyCode, mods: KeyModifiers) {
let ctrl = mods.contains(KeyModifiers::CONTROL);
match code {
KeyCode::Char('q') => return self.quit = true,
KeyCode::Char('c') | KeyCode::Char('d') if ctrl => return self.quit = true,
KeyCode::Tab | KeyCode::Char('s') => return self.toggle_view(),
KeyCode::Char('b') if ctrl => {
self.show_sidebar = !self.show_sidebar;
if !self.show_sidebar {
self.focus = Focus::Diff;
}
return;
}
KeyCode::Char('l') if ctrl => return self.needs_clear = true,
_ => {}
}
match self.effective_focus() {
Focus::Sidebar => self.on_key_sidebar(code),
Focus::Diff => self.on_key_diff(code, ctrl),
}
}
fn on_key_sidebar(&mut self, code: KeyCode) {
match code {
KeyCode::Char('j') | KeyCode::Down => self.move_sidebar(1),
KeyCode::Char('k') | KeyCode::Up => self.move_sidebar(-1),
KeyCode::Char('g') | KeyCode::Home => self.sidebar_edge(false),
KeyCode::Char('G') | KeyCode::End => self.sidebar_edge(true),
KeyCode::Left | KeyCode::Char('h') => self.fold_dir(true),
KeyCode::Right | KeyCode::Char('l') => self.fold_dir(false),
KeyCode::Char(' ') | KeyCode::Char('o') => self.fold_dir_toggle(),
KeyCode::Enter => self.focus = Focus::Diff,
_ => {}
}
}
fn on_key_diff(&mut self, code: KeyCode, ctrl: bool) {
let page = self.height.max(1);
let half = (self.height / 2).max(1);
match code {
KeyCode::Char('j') | KeyCode::Down => self.move_by(1, 1),
KeyCode::Char('k') | KeyCode::Up => self.move_by(-1, 1),
KeyCode::Char('u') if ctrl => self.move_by(-1, half),
KeyCode::Char(' ') | KeyCode::Char('f') | KeyCode::PageDown => self.move_by(1, page),
KeyCode::Char('b') | KeyCode::PageUp => self.move_by(-1, page),
KeyCode::Char('e') if ctrl => self.scroll_view(1),
KeyCode::Char('y') if ctrl => self.scroll_view(-1),
KeyCode::Char('g') | KeyCode::Home => {
self.sel_anchor = None;
self.selected = self.first_selectable().unwrap_or(0);
self.ensure_visible();
}
KeyCode::Char('G') | KeyCode::End => {
self.sel_anchor = None;
self.selected = self.last_selectable().unwrap_or(0);
self.ensure_visible();
}
KeyCode::Char('n') => self.jump_comment(1),
KeyCode::Char('N') => self.jump_comment(-1),
KeyCode::Enter | KeyCode::Char('o') => self.toggle_comment(),
KeyCode::Char(']') => self.jump_file(1),
KeyCode::Char('[') => self.jump_file(-1),
KeyCode::Char('y') => self.copy_selection(),
KeyCode::Esc => {
self.sel_anchor = None;
if self.sidebar_available() {
self.focus = Focus::Sidebar;
}
}
_ => {}
}
}
fn move_selection(&mut self, delta: isize) {
self.sel_anchor = None;
let (start, end) = self.file_range();
let mut i = self.selected as isize;
loop {
i += delta;
if i < start as isize || i as usize >= end {
return;
}
if self.is_selectable_at(i as usize) {
self.selected = i as usize;
self.ensure_visible();
return;
}
}
}
fn move_by(&mut self, dir: isize, count: usize) {
for _ in 0..count {
let before = self.selected;
self.move_selection(dir);
if self.selected == before {
break; }
}
}
fn scroll_view(&mut self, delta: isize) {
let (start, end) = self.file_range();
let max_top = end.saturating_sub(1).max(start) as isize;
self.scroll = (self.scroll as isize + delta).clamp(start as isize, max_top) as usize;
if self.selected < self.scroll {
if let Some(i) = self.nearest_selectable(self.scroll, 1) {
self.selected = i;
}
} else if self.selected >= self.scroll + self.height {
let last = self.scroll + self.height.saturating_sub(1);
if let Some(i) = self.nearest_selectable(last, -1) {
self.selected = i;
}
}
}
fn nearest_selectable(&self, from: usize, dir: isize) -> Option<usize> {
let (start, end) = self.file_range();
let mut i = from as isize;
while i >= start as isize && (i as usize) < end {
if self.is_selectable_at(i as usize) {
return Some(i as usize);
}
i += dir;
}
None
}
fn ensure_visible(&mut self) {
let (start, end) = self.file_range();
if self.selected < self.scroll {
self.scroll = self.selected;
} else if self.selected >= self.scroll + self.height {
self.scroll = self.selected + 1 - self.height;
}
self.scroll = self.scroll.clamp(start, end.saturating_sub(1).max(start));
}
fn jump_comment(&mut self, dir: isize) {
self.sel_anchor = None;
let (start, end) = self.file_range();
let mut targets: Vec<usize> = Vec::new();
for i in start..end {
if let Some((file_idx, side, line)) = self.anchor_at(i) {
if let Some(file) = self.changeset.files.get(file_idx) {
let path = PathBuf::from(file.display_path());
if self
.comments
.threads
.iter()
.any(|t| t.file == path && t.side == side && t.range.start == line)
{
targets.push(i);
}
}
}
}
if targets.is_empty() {
self.status = "no comments".into();
return;
}
let next = if dir > 0 {
targets
.iter()
.find(|&&i| i > self.selected)
.copied()
.or_else(|| targets.first().copied())
} else {
targets
.iter()
.rev()
.find(|&&i| i < self.selected)
.copied()
.or_else(|| targets.last().copied())
};
if let Some(i) = next {
self.selected = i;
self.ensure_visible();
}
}
fn draw(&mut self, f: &mut Frame) {
let area = f.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(1)])
.split(area);
let body = chunks[0];
let sidebar = self.show_sidebar && self.changeset.files.len() > 1 && body.width >= 60;
let (diff_outer, sidebar_area) = if sidebar {
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(self.sidebar_width), Constraint::Min(1)])
.split(body);
(cols[1], cols[0])
} else {
(body, Rect::default())
};
let diff_focused = self.effective_focus() == Focus::Diff;
let diff_block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(if diff_focused {
THEME.border_focus
} else {
THEME.border_unfocus
}));
let diff_inner = diff_block.inner(diff_outer);
let cw = (diff_inner.width as usize).saturating_sub(8);
if cw != self.comment_wrap && !self.resizing {
self.comment_wrap = cw;
if !self.expanded.is_empty() {
self.rebuild_rows();
}
}
if sidebar {
self.render_sidebar(f, sidebar_area);
}
self.sidebar_area = sidebar_area;
self.sidebar_sb =
if sidebar_area.width > 0 && self.sidebar_rows.len() > sidebar_area.height as usize {
Rect {
x: sidebar_area.x + sidebar_area.width.saturating_sub(1),
y: sidebar_area.y,
width: 1,
height: sidebar_area.height,
}
} else {
Rect::default()
};
f.render_widget(diff_block, diff_outer);
self.height = diff_inner.height as usize;
let (fr_start, fr_end) = self.file_range();
let overflow = fr_end - fr_start > self.height;
let content = if overflow {
Rect {
width: diff_inner.width.saturating_sub(1),
..diff_inner
}
} else {
diff_inner
};
self.diff_area = content;
self.diff_sb = if overflow {
Rect {
x: diff_inner.x + diff_inner.width.saturating_sub(1),
y: diff_inner.y,
width: 1,
height: diff_inner.height,
}
} else {
Rect::default()
};
self.render_diff(f, content);
if overflow {
self.render_diff_scrollbar(f, diff_inner);
}
f.render_widget(
Paragraph::new(self.status.clone()).style(Style::default().fg(THEME.muted)),
chunks[1],
);
}
fn render_diff(&self, f: &mut Frame, area: Rect) {
match self.view {
View::Unified => self.render_unified(f, area),
View::Split => self.render_split(f, area),
}
}
fn render_sidebar(&self, f: &mut Frame, area: Rect) {
let focused = self.effective_focus() == Focus::Sidebar;
let inner = area;
let h = inner.height as usize;
let n = self.sidebar_rows.len();
let need_sb = n > h;
let w = (inner.width as usize).saturating_sub(if need_sb { 1 } else { 0 });
let max = n.saturating_sub(h);
let scroll = self.sidebar_scroll.min(max);
let mut lines: Vec<Line> = Vec::new();
for idx in scroll..n.min(scroll + h) {
let is_cursor = focused && idx == self.sidebar_sel;
match &self.sidebar_rows[idx] {
SbRow::Dir { name, depth, path } => {
let indent = " ".repeat(depth + 1);
let arrow = if self.collapsed.contains(path) {
"▶ "
} else {
"▼ "
};
let avail = w.saturating_sub(indent.chars().count() + 2);
let label = format!("{:<width$}", elide_left(name, avail), width = avail);
let bg = if is_cursor {
Some(THEME.cursor_bg)
} else {
None
};
let wbg = |st: Style| match bg {
Some(b) => st.bg(b),
None => st,
};
lines.push(Line::from(vec![
Span::styled(indent, wbg(Style::default())),
Span::styled(arrow, wbg(Style::default().fg(THEME.faint))),
Span::styled(label, wbg(Style::default().fg(THEME.faint))),
]));
}
SbRow::File { idx: fi, depth } => {
let fi = *fi;
let is_cur = fi == self.current_file;
let (status, status_color) = self
.changeset
.files
.get(fi)
.map(file_status)
.unwrap_or(('M', THEME.warn));
let indent = " ".repeat(depth + 1);
let path = self
.changeset
.files
.get(fi)
.map(|f| f.display_path())
.unwrap_or_default();
let (dot, dot_color) = match file_comment_state(&self.comments, path) {
Some(true) => ("● ", THEME.warn),
Some(false) => ("○ ", THEME.muted),
None => (" ", THEME.none),
};
let (adds, dels) = self.file_stats.get(fi).copied().unwrap_or((0, 0));
let counts = format!(" +{adds} -{dels}");
let avail = w
.saturating_sub(indent.chars().count() + 4)
.saturating_sub(counts.chars().count());
let base = base_of(path);
let name = format!("{:<width$}", elide_left(base, avail), width = avail);
let bg = if is_cursor {
Some(THEME.cursor_bg)
} else if is_cur {
Some(THEME.unfocus_bg)
} else {
None
};
let wbg = |st: Style| match bg {
Some(b) => st.bg(b),
None => st,
};
let name_style = if is_cur {
Style::default()
.fg(THEME.text_strong)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(THEME.text)
};
lines.push(Line::from(vec![
Span::styled(indent, wbg(Style::default())),
Span::styled(format!("{status} "), wbg(Style::default().fg(status_color))),
Span::styled(dot, wbg(Style::default().fg(dot_color))),
Span::styled(name, wbg(name_style)),
Span::styled(format!(" +{adds}"), wbg(Style::default().fg(THEME.added))),
Span::styled(format!(" -{dels}"), wbg(Style::default().fg(THEME.removed))),
]));
}
}
}
f.render_widget(Paragraph::new(lines), inner);
if need_sb {
let mut sb = ScrollbarState::new(max + 1)
.position(scroll)
.viewport_content_length(h);
f.render_stateful_widget(
Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(None)
.end_symbol(None)
.track_symbol(Some(" "))
.thumb_symbol("█")
.thumb_style(Style::default().fg(THEME.scrollbar_thumb)),
inner,
&mut sb,
);
}
}
fn render_diff_scrollbar(&self, f: &mut Frame, area: Rect) {
let (start, end) = self.file_range();
let total = end - start;
if total <= self.height {
return;
}
let max_top = total - self.height;
let pos = self.scroll.saturating_sub(start).min(max_top);
let mut sb = ScrollbarState::new(max_top + 1)
.position(pos)
.viewport_content_length(self.height);
f.render_stateful_widget(
Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(None)
.end_symbol(None)
.track_symbol(Some(" "))
.thumb_symbol("█")
.thumb_style(Style::default().fg(THEME.scrollbar_thumb)),
area,
&mut sb,
);
}
fn render_unified(&self, f: &mut Frame, area: Rect) {
let width = area.width as usize;
let (start, file_end) = self.file_range();
let top = self.scroll.max(start);
let end = (top + self.height).min(file_end);
let mut lines: Vec<Line> = Vec::new();
for idx in top..end {
let row = &self.rows[idx];
let selected = self.in_selection(idx);
lines.push(self.row_to_line(row, selected, width));
}
f.render_widget(Paragraph::new(lines), area);
}
fn render_split(&self, f: &mut Frame, area: Rect) {
let total = area.width as usize;
let divider = " │ ";
let side_w = total.saturating_sub(divider.len()) / 2;
let (start, file_end) = self.file_range();
let top = self.scroll.max(start);
let end = (top + self.height).min(file_end);
let mut lines: Vec<Line> = Vec::new();
for idx in top..end {
let row = &self.split_rows[idx];
let selected = self.in_selection(idx);
lines.push(self.split_row_to_line(row, selected, side_w, divider));
}
f.render_widget(Paragraph::new(lines), area);
}
fn split_row_to_line(
&self,
row: &SplitRow,
selected: bool,
side_w: usize,
divider: &str,
) -> Line<'static> {
match &row.kind {
SplitRowKind::FileHeader => Line::from(Span::styled(
format!("▌ {}", row.text),
Style::default()
.fg(THEME.text_strong)
.bg(THEME.file_header_bg)
.add_modifier(Modifier::BOLD),
)),
SplitRowKind::HunkHeader => Line::from(Span::styled(
row.text.clone(),
Style::default()
.fg(THEME.faint)
.add_modifier(Modifier::ITALIC),
)),
SplitRowKind::Pair { left, right } => {
let mut spans =
self.side_spans(left.as_ref(), Side::Old, row.file_idx, side_w, selected);
spans.push(Span::styled(
divider.to_string(),
Style::default().fg(THEME.subtle),
));
spans.extend(self.side_spans(
right.as_ref(),
Side::New,
row.file_idx,
side_w,
selected,
));
Line::from(spans)
}
SplitRowKind::Comment(cl) => {
let pad = side_w + divider.chars().count();
let mut spans = vec![Span::styled(" ".repeat(pad), Style::default())];
spans.extend(self.comment_line_to_line(cl, side_w).spans);
Line::from(spans)
}
}
}
fn side_spans(
&self,
cell: Option<&SideCell>,
_side: Side,
file_idx: usize,
width: usize,
selected: bool,
) -> Vec<Span<'static>> {
const PREFIX: usize = 5; match cell {
None => vec![Span::styled(
" ".repeat(width),
Style::default().bg(THEME.comment_bg),
)],
Some(c) => {
let num = c
.line
.map(|n| format!("{n:>4}"))
.unwrap_or_else(|| " ".into());
let bg = if selected {
Some(self.diff_cursor_bg())
} else {
match c.kind {
LineKind::Addition => Some(THEME.add_bg),
LineKind::Deletion => Some(THEME.del_bg),
LineKind::Context => None,
}
};
let mut spans = vec![Span::styled(
format!("{num} "),
Style::default().fg(THEME.muted),
)];
spans.extend(self.styled_fit(file_idx, &c.text, width.saturating_sub(PREFIX), bg));
spans
}
}
}
fn row_to_line(&self, row: &Row, selected: bool, width: usize) -> Line<'static> {
match &row.kind {
RowKind::FileHeader => {
let st = Style::default()
.fg(THEME.text_strong)
.bg(THEME.file_header_bg)
.add_modifier(Modifier::BOLD);
let text = format!("▌ {}", row.text);
let pad = width.saturating_sub(text.chars().count());
Line::from(vec![
Span::styled(text, st),
Span::styled(" ".repeat(pad), st),
])
}
RowKind::HunkHeader => Line::from(Span::styled(
row.text.clone(),
Style::default()
.fg(THEME.faint)
.add_modifier(Modifier::ITALIC),
)),
RowKind::Line {
kind,
old_line,
new_line,
} => {
let num = format!(
"{:>5} {:>5} ",
old_line.map(|n| n.to_string()).unwrap_or_default(),
new_line.map(|n| n.to_string()).unwrap_or_default(),
);
let (sign, code) = row.text.split_at(1);
let bg = if selected {
Some(self.diff_cursor_bg())
} else {
match kind {
LineKind::Addition => Some(THEME.add_bg),
LineKind::Deletion => Some(THEME.del_bg),
LineKind::Context => None,
}
};
let sign_color = match kind {
LineKind::Addition => THEME.added,
LineKind::Deletion => THEME.removed,
LineKind::Context => THEME.muted,
};
let with_bg = |st: Style| match bg {
Some(b) => st.bg(b),
None => st,
};
let mut used = num.chars().count() + 1;
let mut spans = vec![
Span::styled(num, with_bg(Style::default().fg(THEME.muted))),
Span::styled(sign.to_string(), with_bg(Style::default().fg(sign_color))),
];
let hl = self.highlight(row.file_idx, code);
for (c, s) in hl.iter() {
used += s.chars().count();
spans.push(Span::styled(s.clone(), with_bg(Style::default().fg(*c))));
}
if bg.is_some() && used < width {
spans.push(Span::styled(
" ".repeat(width - used),
with_bg(Style::default()),
));
}
Line::from(spans)
}
RowKind::Comment(cl) => self.comment_line_to_line(cl, width),
}
}
fn comment_line_to_line(&self, cl: &CommentLine, width: usize) -> Line<'static> {
const MARGIN: usize = 2;
let border_col = THEME.text_strong;
let bstyle = Style::default().fg(border_col);
let inner_w = width.saturating_sub(MARGIN + 2);
if width <= MARGIN + 2 {
return Line::from(Span::raw(" ".repeat(width)));
}
let margin = Span::raw(" ".repeat(MARGIN));
match cl {
CommentLine::Top => Line::from(vec![
margin,
Span::styled(format!("╭{}╮", "─".repeat(inner_w)), bstyle),
]),
CommentLine::Bottom => Line::from(vec![
margin,
Span::styled(format!("╰{}╯", "─".repeat(inner_w)), bstyle),
]),
CommentLine::Author { name, date } => {
let left = format!(" @{name}");
let llen = left.chars().count();
let dlen = date.chars().count();
let name_style = Style::default().fg(THEME.warn).add_modifier(Modifier::BOLD);
let inner = if llen + dlen + 2 <= inner_w {
vec![
Span::styled(left, name_style),
Span::raw(" ".repeat(inner_w - llen - dlen - 1)),
Span::styled(date.clone(), Style::default().fg(THEME.muted)),
Span::raw(" "),
]
} else {
let l: String = left.chars().take(inner_w).collect();
let pad = inner_w - l.chars().count();
vec![Span::styled(l, name_style), Span::raw(" ".repeat(pad))]
};
let mut spans = vec![margin, Span::styled("│".to_string(), bstyle)];
spans.extend(inner);
spans.push(Span::styled("│".to_string(), bstyle));
Line::from(spans)
}
_ => {
let (content, color, bold) = match cl {
CommentLine::Head { resolved, replies } => (
format!(
" {} · {} message{}",
if *resolved { "resolved" } else { "open" },
replies,
if *replies == 1 { "" } else { "s" }
),
if *resolved { THEME.muted } else { THEME.accent },
true,
),
CommentLine::Body(b) => (format!(" {b}"), THEME.text, false),
_ => (String::new(), THEME.text, false), };
let mut cstyle = Style::default().fg(color);
if bold {
cstyle = cstyle.add_modifier(Modifier::BOLD);
}
let clen = content.chars().count();
let content = if clen > inner_w {
content.chars().take(inner_w).collect::<String>()
} else {
format!("{content}{}", " ".repeat(inner_w - clen))
};
Line::from(vec![
margin,
Span::styled("│".to_string(), bstyle),
Span::styled(content, cstyle),
Span::styled("│".to_string(), bstyle),
])
}
}
}
}