use crate::comments::model::{CommentStore, LineRange};
use crate::diff::model::{Changeset, LineKind, Side};
use crate::ui::highlight_cache::HighlightCache;
use crate::ui::render_rows::{
build_file_rows, build_file_split_rows, build_rows, build_split_rows, char_width, str_width,
take_width, CommentKind, CommentLine, ComposerAnchor, ComposerKind, ComposerLine, ComposerSpec,
Row, RowKind, SideCell, SplitRow, SplitRowKind,
};
use crate::ui::sidebar::{
base_of, build_sidebar_rows, dir_of, file_comment_state, file_status, SbRow,
};
use crate::ui::theme::theme;
use anyhow::Result;
use crossterm::event::{
self, Event, KeyCode, KeyEvent, 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::HashSet;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use tui_textarea::TextArea;
#[derive(Clone, Copy, PartialEq, Eq)]
enum View {
Unified,
Split,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Focus {
Sidebar,
Diff,
}
const SIDEBAR_WIDTH: u16 = 38;
const MIN_SIDEBAR: u16 = 14;
const MIN_DIFF: u16 = 20;
const SPLIT_DIVIDER: &str = " │ ";
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 pad_width(s: &str, w: usize) -> String {
let sw = str_width(s);
if sw >= w {
s.to_string()
} else {
format!("{s}{}", " ".repeat(w - sw))
}
}
fn elide_left(s: &str, w: usize) -> String {
if str_width(s) <= w {
return s.to_string();
}
if w <= 1 {
return "…".repeat(w);
}
let budget = w - 1;
let mut tail: std::collections::VecDeque<char> = std::collections::VecDeque::new();
let mut used = 0usize;
for c in s.chars().rev() {
let cw = char_width(c);
if used + cw > budget {
break;
}
tail.push_front(c);
used += cw;
}
let tail: String = tail.into_iter().collect();
format!("…{tail}")
}
#[inline]
fn wrap_break_before(w: usize, cw: usize, budget: usize) -> bool {
w > 0 && w + cw > budget
}
fn wrap_runs(
runs: &[(Color, String)],
budget: usize,
bg: Option<Color>,
) -> Vec<(Vec<Span<'static>>, usize)> {
let budget = budget.max(1);
let style = |c: Color| {
let mut st = Style::default().fg(c);
if let Some(b) = bg {
st = st.bg(b);
}
st
};
let mut lines: Vec<(Vec<Span<'static>>, usize)> = Vec::new();
let mut cur: Vec<Span<'static>> = Vec::new();
let mut w = 0usize;
for (c, s) in runs {
let mut buf = String::new();
for ch in s.chars() {
let cw = char_width(ch);
if wrap_break_before(w, cw, budget) {
if !buf.is_empty() {
cur.push(Span::styled(std::mem::take(&mut buf), style(*c)));
}
lines.push((std::mem::take(&mut cur), w));
w = 0;
}
buf.push(ch);
w += cw;
}
if !buf.is_empty() {
cur.push(Span::styled(buf, style(*c)));
}
}
lines.push((cur, w));
lines
}
fn wrap_count(text: &str, budget: usize) -> usize {
let budget = budget.max(1);
let mut lines = 1usize;
let mut w = 0usize;
for ch in text.chars() {
let cw = char_width(ch);
if wrap_break_before(w, cw, budget) {
lines += 1;
w = 0;
}
w += cw;
}
lines
}
enum SelKey {
Line(usize, Side, u32),
Comment(String),
}
enum ComposeTarget {
NewThread {
file_idx: usize,
side: Side,
start: u32,
end: u32,
},
Reply { thread_id: String },
}
struct Composer {
target: ComposeTarget,
textarea: TextArea<'static>,
}
const COMPOSER_CARET: char = '\u{2060}';
fn body_with_caret(ta: &TextArea<'static>) -> String {
let (row, col) = ta.cursor();
let lines = ta.lines();
let cap = lines.iter().map(|l| l.len()).sum::<usize>()
+ lines.len().saturating_sub(1)
+ COMPOSER_CARET.len_utf8();
let mut out = String::with_capacity(cap);
for (i, line) in lines.iter().enumerate() {
if i > 0 {
out.push('\n');
}
if i == row {
let byte = line
.char_indices()
.nth(col)
.map(|(b, _)| b)
.unwrap_or(line.len());
out.push_str(&line[..byte]);
out.push(COMPOSER_CARET);
out.push_str(&line[byte..]);
} else {
out.push_str(line);
}
}
out
}
#[derive(Clone, Debug)]
enum ButtonAction {
AddComment,
Submit,
Cancel,
Reply(String),
ToggleResolve(String),
Delete(String, String),
}
pub struct App {
changeset: Arc<Changeset>,
rows: Vec<Row>,
comments: CommentStore,
base_comment_ids: HashSet<String>,
split_rows: Vec<SplitRow>,
view: View,
selected: usize, scroll: usize, height: usize, status: String,
needs_clear: bool,
show_sidebar: bool,
sidebar_width: u16,
sidebar_scroll: usize, sidebar_sel: 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>, button_hits: RefCell<Vec<(Rect, ButtonAction)>>,
hl: HighlightCache,
file_span: (usize, usize),
file_spans: Vec<(usize, usize)>,
composer: Option<Composer>,
visual: bool,
wrap: bool,
geom: wrap::WrapGeom,
unified_dirty: bool,
split_dirty: bool,
quit: bool,
}
mod comments;
mod input;
mod nav;
mod render;
mod render_boxes;
mod render_lines;
mod wrap;
impl App {
pub fn into_comments(self) -> CommentStore {
self.comments
}
pub fn with_comments(changeset: Changeset, comments: CommentStore) -> Self {
let rows = build_rows(&changeset, &comments, 0, None);
let split_rows = build_split_rows(&changeset, &comments, 0, None);
let base_comment_ids: HashSet<String> = comments
.threads
.iter()
.flat_map(|t| t.comments.iter().map(|c| c.id.clone()))
.collect();
let stats = file_stats(&changeset);
let collapsed = HashSet::new();
let (sidebar_rows, file_to_sbrow) = build_sidebar_rows(&changeset, &collapsed);
let changeset = Arc::new(changeset);
let hl = HighlightCache::new(changeset.clone());
let mut app = App {
changeset,
rows,
split_rows,
view: View::Split,
comments,
base_comment_ids,
selected: 0,
scroll: 0,
height: 1,
status: String::new(),
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),
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,
button_hits: RefCell::new(Vec::new()),
hl,
file_span: (0, 0),
file_spans: Vec::new(),
composer: None,
visual: false,
wrap: true,
geom: wrap::WrapGeom {
width: usize::MAX,
dirty: true,
..Default::default()
},
unified_dirty: false,
split_dirty: false,
quit: false,
};
app.resync_file_spans();
app.selected = app.first_selectable().unwrap_or(0);
app
}
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))?;
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),
Event::Paste(text) => self.on_paste(text),
_ => {}
}
while event::poll(Duration::from_millis(0))? {
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),
Event::Paste(text) => self.on_paste(text),
_ => {}
}
}
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::stderr();
let _ = out.write_all(seq.as_bytes());
let _ = out.flush();
}
}
Ok(())
}
}
#[cfg(test)]
mod tests;