use std::collections::{HashMap, HashSet};
use ratatui::layout::Rect;
use ratatui::style::Color;
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc::UnboundedSender;
use crate::action::Action;
use crate::cli::DiffRange;
use crate::diff::{DiffLine, FileDiff, LineKind, build_split_rows};
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum, Deserialize)]
#[serde(rename_all = "lowercase")]
#[derive(Default)]
pub enum ThemeName {
#[default]
Classic,
Washi,
Sumi,
Dark,
Light,
Deuteranopia,
}
impl ThemeName {
pub fn next(self) -> Self {
match self {
ThemeName::Classic => ThemeName::Washi,
ThemeName::Washi => ThemeName::Sumi,
ThemeName::Sumi => ThemeName::Classic,
ThemeName::Dark | ThemeName::Light | ThemeName::Deuteranopia => ThemeName::Classic,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Focus {
Files,
Diff,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Normal,
Insert,
Search,
FileFilter,
Visual,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Side {
Old,
New,
}
impl Side {
pub fn tag(self) -> &'static str {
match self {
Side::Old => "-",
Side::New => "+",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UnderlineKind {
None,
Dim,
Bright,
}
#[derive(Debug, Clone)]
pub struct Comment {
pub file: String,
pub side: Side,
pub lineno: u32,
pub lineno_end: Option<u32>,
pub body: String,
pub replies: Vec<String>,
}
impl Comment {
pub fn anchored_at(&self, file: &str, side: Side, lineno: u32) -> bool {
self.file == file && self.side == side && self.lineno == lineno
}
pub fn location(&self) -> String {
match self.lineno_end {
Some(end) => format!("{}:L{}{}-L{}", self.file, self.lineno, self.side.tag(), end),
None => format!("{}:L{}{}", self.file, self.lineno, self.side.tag()),
}
}
}
pub fn line_anchor(line: &DiffLine) -> Option<(Side, u32)> {
match line.kind {
LineKind::Removed => line.old_lineno.map(|n| (Side::Old, n)),
LineKind::Added => line.new_lineno.map(|n| (Side::New, n)),
LineKind::Context => line.new_lineno.map(|n| (Side::New, n)),
_ => None,
}
}
pub fn find_display_index(file: &FileDiff, side: Side, lineno: u32) -> Option<usize> {
file.lines
.iter()
.position(|l| line_anchor(l) == Some((side, lineno)))
}
fn comment_plus_replies_rows(c: &Comment) -> usize {
let mut n = 3 + c.body.matches('\n').count() + 1;
for r in &c.replies {
n += r.matches('\n').count() + 1;
}
n
}
#[derive(Clone, Copy)]
struct CodeLayout {
header: usize,
indent: usize,
}
const UNIFIED_LAYOUT: CodeLayout = CodeLayout {
header: 14,
indent: 12, };
const SPLIT_LAYOUT: CodeLayout = CodeLayout {
header: crate::ui::SPLIT_GUTTER_WIDTH,
indent: crate::ui::SPLIT_GUTTER_WIDTH,
};
fn line_visual_rows(line: &DiffLine, full_width: usize, m: CodeLayout) -> usize {
if line.kind.is_fixed_height() || full_width == 0 {
return 1;
}
let text_width = unicode_width::UnicodeWidthStr::width(line.text.as_str());
let content_width = text_width + m.header;
if content_width <= full_width {
return 1;
}
let avail_rest = full_width.saturating_sub(m.indent);
if avail_rest == 0 {
return 1;
}
1 + (content_width - full_width).div_ceil(avail_rest)
}
pub struct App {
pub files: Vec<FileDiff>,
pub range: DiffRange,
pub diff_fingerprint: String,
pub selected: usize,
pub diff_scroll: usize,
pub cursor_line: usize,
pub focus: Focus,
pub mode: Mode,
pub input: String,
pub input_cursor: usize,
pub comments: Vec<Comment>,
pub should_quit: bool,
pub viewport_height: usize,
pub pending_g: bool,
pub pending_d: bool,
pub show_comments_list: bool,
pub comments_list_cursor: usize,
pub pending_expand: Option<usize>,
pub file_list_area: Rect,
pub diff_area: Rect,
pub split_view: bool,
pub preview_mode: bool,
pub comment_modal: bool,
pub show_file_list: bool,
pub show_help: bool,
pub show_hints: bool,
pub diff_row_map: Vec<usize>,
pub comment_focus: Option<usize>,
pub editing_comment: Option<usize>,
pub search_query: Option<String>,
pub search_matches: Vec<(usize, usize)>,
pub search_match_cursor: usize,
pub file_filter: Option<String>,
pub file_list_width: u16,
pub expanded_dirs: HashSet<String>,
pub visual_start: Option<usize>,
pub theme_name: ThemeName,
pub color_overrides: HashMap<String, Color>,
pub no_emoji: bool,
pub action_tx: Option<UnboundedSender<Action>>,
pub comment_line_end: Option<usize>,
pub pending_open_editor: bool,
pub pending_reload: bool,
pub show_revision_selector: bool,
pub revisions: Vec<String>,
pub revision_cursor: usize,
pub pending_range_change: Option<String>,
pub file_tree_cursor: usize,
}
impl App {
pub fn new(files: Vec<FileDiff>, range: DiffRange, theme_name: ThemeName) -> Self {
let initial_selected = crate::ui::tree_file_order(&files)
.first()
.copied()
.unwrap_or(0);
let diff_fingerprint = crate::storage::diff_fingerprint(&files);
let mut app = Self {
files,
range,
diff_fingerprint,
selected: initial_selected,
diff_scroll: 0,
cursor_line: 0,
focus: Focus::Diff,
mode: Mode::Normal,
input: String::new(),
input_cursor: 0,
comments: Vec::new(),
should_quit: false,
viewport_height: 20,
pending_g: false,
pending_d: false,
show_comments_list: false,
comments_list_cursor: 0,
pending_expand: None,
file_list_area: Rect::default(),
diff_area: Rect::default(),
split_view: false,
preview_mode: false,
comment_modal: true,
show_file_list: true,
show_help: false,
show_hints: true,
diff_row_map: Vec::new(),
comment_focus: None,
editing_comment: None,
search_query: None,
search_matches: Vec::new(),
search_match_cursor: 0,
file_filter: None,
file_list_width: 34,
expanded_dirs: HashSet::new(),
visual_start: None,
theme_name,
color_overrides: HashMap::new(),
no_emoji: false,
action_tx: None,
comment_line_end: None,
pending_open_editor: false,
pending_reload: false,
show_revision_selector: false,
revisions: Vec::new(),
revision_cursor: 0,
pending_range_change: None,
file_tree_cursor: 0,
};
for file in &app.files {
if let Some(dir) = file.path.rsplit_once('/').map(|x| x.0) {
let mut cur = String::new();
for (i, seg) in dir.split('/').enumerate() {
if i > 0 {
cur.push('/');
}
cur.push_str(seg);
app.expanded_dirs.insert(cur.clone());
}
}
}
app.cursor_line = app.first_navigable_line();
app.sync_file_tree_cursor_to_selected();
app
}
fn flat_tree(&self) -> Vec<crate::ui::FlatFileTreeEntry> {
crate::ui::flatten_file_tree(
&self.files,
&self.comments,
&self.expanded_dirs,
self.file_filter.as_deref(),
)
}
pub fn file_tree_cursor_down(&mut self) {
let entries = self.flat_tree();
if entries.is_empty() {
return;
}
let n = entries.len();
self.file_tree_cursor = (self.file_tree_cursor + 1).min(n - 1);
self.apply_file_tree_cursor_selection(&entries);
}
pub fn file_tree_cursor_up(&mut self) {
let entries = self.flat_tree();
if entries.is_empty() {
return;
}
self.file_tree_cursor = self.file_tree_cursor.saturating_sub(1);
self.apply_file_tree_cursor_selection(&entries);
}
fn apply_file_tree_cursor_selection(&mut self, entries: &[crate::ui::FlatFileTreeEntry]) {
if let Some(crate::ui::FlatFileTreeEntry::File { file_idx }) =
entries.get(self.file_tree_cursor)
{
if *file_idx != self.selected {
self.selected = *file_idx;
self.reset_view();
}
}
}
pub fn toggle_file_tree_dir_at_cursor(&mut self) -> bool {
let entries = self.flat_tree();
let Some(crate::ui::FlatFileTreeEntry::Dir { path }) = entries.get(self.file_tree_cursor)
else {
return false;
};
let path = path.clone();
if self.expanded_dirs.contains(&path) {
self.expanded_dirs.remove(&path);
} else {
self.expanded_dirs.insert(path);
}
let new_len = self.flat_tree().len();
if new_len > 0 {
self.file_tree_cursor = self.file_tree_cursor.min(new_len - 1);
}
true
}
pub fn sync_file_tree_cursor_to_selected(&mut self) {
if let Some(file) = self.files.get(self.selected) {
for parent in crate::ui::get_parent_dirs(&file.path) {
self.expanded_dirs.insert(parent);
}
}
let entries = self.flat_tree();
if let Some(pos) = entries.iter().position(|e| {
matches!(
e,
crate::ui::FlatFileTreeEntry::File { file_idx } if *file_idx == self.selected
)
}) {
self.file_tree_cursor = pos;
}
}
pub fn current(&self) -> Option<&FileDiff> {
self.files.get(self.selected)
}
pub fn file_tree_cursor_on_dir(&self) -> bool {
matches!(
self.flat_tree().get(self.file_tree_cursor),
Some(crate::ui::FlatFileTreeEntry::Dir { .. })
)
}
pub fn cursor_on_fold(&self) -> bool {
self.current()
.and_then(|f| f.lines.get(self.cursor_line))
.map(|l| l.kind.is_fold())
.unwrap_or(false)
}
pub fn current_path(&self) -> String {
self.current().map(|f| f.path.clone()).unwrap_or_default()
}
pub fn current_lineno(&self) -> String {
self.current()
.and_then(|f| f.lines.get(self.cursor_line))
.map(|l| {
l.new_lineno
.or(l.old_lineno)
.map(|n| n.to_string())
.unwrap_or_else(|| "-".to_string())
})
.unwrap_or_else(|| "-".to_string())
}
pub fn select_next_file(&mut self) {
self.step_file_in_tree_order(1);
}
pub fn select_prev_file(&mut self) {
self.step_file_in_tree_order(-1);
}
fn filtered_tree_order(&self) -> Vec<usize> {
let mut order = crate::ui::tree_file_order(&self.files);
if let Some(ref q) = self.file_filter {
let q_lower = q.to_lowercase();
order.retain(|&i| self.files[i].path.to_lowercase().contains(&q_lower));
}
order
}
fn step_file_in_tree_order(&mut self, delta: isize) {
let order = self.filtered_tree_order();
if order.is_empty() {
return;
}
let cur_pos = order.iter().position(|&i| i == self.selected).unwrap_or(0);
let n = order.len() as isize;
let next_pos = ((cur_pos as isize + delta).rem_euclid(n)) as usize;
self.selected = order[next_pos];
self.reset_view();
self.sync_file_tree_cursor_to_selected();
}
pub fn select_first_file(&mut self) {
let order = self.filtered_tree_order();
let Some(&first) = order.first() else { return };
self.selected = first;
self.reset_view();
self.sync_file_tree_cursor_to_selected();
}
pub fn select_last_file(&mut self) {
let order = self.filtered_tree_order();
let Some(&last) = order.last() else { return };
self.selected = last;
self.reset_view();
self.sync_file_tree_cursor_to_selected();
}
fn reset_view(&mut self) {
self.cursor_line = self.first_navigable_line();
self.diff_scroll = 0;
self.comment_focus = None;
self.ensure_cursor_visible();
}
pub fn total_lines(&self) -> usize {
self.current().map(|f| f.lines.len()).unwrap_or(0)
}
fn is_code_line(&self, idx: usize) -> bool {
self.current()
.and_then(|f| f.lines.get(idx))
.map(|l| l.kind.is_code())
.unwrap_or(false)
}
pub fn first_navigable_line(&self) -> usize {
self.current()
.and_then(|f| f.lines.iter().position(|l| l.kind.is_navigable()))
.unwrap_or(0)
}
fn last_navigable_line(&self) -> usize {
self.current()
.and_then(|f| f.lines.iter().rposition(|l| l.kind.is_navigable()))
.unwrap_or(0)
}
fn step_down(&self, from: usize) -> Option<usize> {
let file = self.current()?;
((from + 1)..file.lines.len()).find(|&i| file.lines[i].kind.is_navigable())
}
fn step_up(&self, from: usize) -> Option<usize> {
let file = self.current()?;
(0..from).rev().find(|&i| file.lines[i].kind.is_navigable())
}
fn comment_count_on_cursor(&self) -> usize {
self.current()
.map(|f| self.comments_on(f, self.cursor_line).len())
.unwrap_or(0)
}
pub fn move_cursor_down(&mut self, n: usize) {
let skip_comments = self.mode == Mode::Visual;
for _ in 0..n {
if !skip_comments {
let cc = self.comment_count_on_cursor();
if let Some(cf) = self.comment_focus {
if cf + 1 < cc {
self.comment_focus = Some(cf + 1);
continue;
}
} else if cc > 0 {
self.comment_focus = Some(0);
continue;
}
}
self.comment_focus = None;
match self.step_down(self.cursor_line) {
Some(i) => self.cursor_line = i,
None => break,
}
}
self.ensure_cursor_visible();
}
pub fn move_cursor_up(&mut self, n: usize) {
let skip_comments = self.mode == Mode::Visual;
for _ in 0..n {
if !skip_comments {
if let Some(cf) = self.comment_focus {
if cf > 0 {
self.comment_focus = Some(cf - 1);
continue;
} else {
self.comment_focus = None;
continue;
}
}
}
match self.step_up(self.cursor_line) {
Some(i) => {
self.cursor_line = i;
if !skip_comments {
let cc = self
.current()
.map(|f| self.comments_on(f, i).len())
.unwrap_or(0);
if cc > 0 {
self.comment_focus = Some(cc - 1);
}
}
}
None => break,
}
}
self.ensure_cursor_visible();
}
pub fn cursor_top(&mut self) {
self.cursor_line = self.first_navigable_line();
self.ensure_cursor_visible();
}
pub fn cursor_bottom(&mut self) {
self.cursor_line = self.last_navigable_line();
self.ensure_cursor_visible();
}
pub fn center_cursor(&mut self) {
if self.split_view {
self.center_cursor_split();
} else {
let half = self.viewport_height / 2;
self.diff_scroll = self.cursor_line.saturating_sub(half);
}
}
fn center_cursor_split(&mut self) {
let file = match self.current() {
Some(f) => f,
None => return,
};
let split_rows = build_split_rows(file);
let cursor_row = split_rows
.iter()
.position(|r| r.left == Some(self.cursor_line) || r.right == Some(self.cursor_line))
.unwrap_or(0);
let half = self.viewport_height / 2;
let target_row = cursor_row.saturating_sub(half);
if let Some(row) = split_rows.get(target_row) {
self.diff_scroll = row.left.or(row.right).unwrap_or(0);
}
}
fn land_on_hunk(&mut self, i: usize) {
let kind = self.current().unwrap().lines[i].kind;
self.cursor_line = i;
if kind == crate::diff::LineKind::HunkHeader {
if let Some(next) = self.step_down(i) {
self.cursor_line = next;
}
}
self.comment_focus = None;
self.ensure_cursor_visible();
}
pub fn next_hunk(&mut self) {
let file = match self.current() {
Some(f) => f,
None => return,
};
for i in (self.cursor_line + 1)..file.lines.len() {
if file.lines[i].kind.is_hunk_boundary() {
self.land_on_hunk(i);
return;
}
}
}
pub fn prev_hunk(&mut self) {
let file = match self.current() {
Some(f) => f,
None => return,
};
let mut found_current = false;
for i in (0..self.cursor_line).rev() {
if file.lines[i].kind.is_hunk_boundary() {
if !found_current {
found_current = true;
continue;
}
self.land_on_hunk(i);
return;
}
}
if found_current {
self.cursor_top();
}
}
pub fn ensure_cursor_visible(&mut self) {
if self.split_view {
self.ensure_cursor_visible_split();
} else {
self.ensure_cursor_visible_unified();
}
}
fn ensure_cursor_visible_unified(&mut self) {
let vh = self.viewport_height.max(1);
if self.cursor_line < self.diff_scroll {
self.diff_scroll = self.cursor_line;
}
let mut visual = self.visual_rows_unified(self.diff_scroll, self.cursor_line);
while self.diff_scroll < self.cursor_line && visual > vh {
visual = visual.saturating_sub(self.row_visual_rows_unified(self.diff_scroll));
self.diff_scroll += 1;
}
}
fn ensure_cursor_visible_split(&mut self) {
let file = match self.current() {
Some(f) => f,
None => return,
};
let vh = self.viewport_height.max(1);
let split_rows = build_split_rows(file);
let scroll_row = split_rows
.iter()
.position(|r| r.left == Some(self.diff_scroll) || r.right == Some(self.diff_scroll))
.unwrap_or(0);
let cursor_row = split_rows
.iter()
.position(|r| r.left == Some(self.cursor_line) || r.right == Some(self.cursor_line))
.unwrap_or(0);
if cursor_row < scroll_row {
if let Some(row) = split_rows.get(cursor_row) {
self.diff_scroll = row.left.or(row.right).unwrap_or(0);
}
return;
}
let side_width = (self.diff_area.width as usize).saturating_sub(1) / 2;
let mut visible = self.visual_rows_split(scroll_row, cursor_row, &split_rows);
let mut sr = scroll_row;
while sr < cursor_row && visible > vh {
visible =
visible.saturating_sub(self.row_visual_rows_split(&split_rows[sr], side_width));
sr += 1;
}
if sr != scroll_row {
if let Some(row) = split_rows.get(sr) {
self.diff_scroll = row.left.or(row.right).unwrap_or(0);
}
}
}
fn row_visual_rows_unified(&self, idx: usize) -> usize {
let Some(file) = self.current() else {
return 0;
};
let Some(line) = file.lines.get(idx) else {
return 0;
};
let full_width = self.diff_area.width as usize;
let mut count = line_visual_rows(line, full_width, UNIFIED_LAYOUT);
for c in self.comments_on(file, idx) {
count += comment_plus_replies_rows(c);
}
count + self.insert_draft_rows(idx)
}
fn visual_rows_unified(&self, from: usize, to: usize) -> usize {
let Some(file) = self.current() else {
return 0;
};
let end = to.min(file.lines.len().saturating_sub(1));
(from..=end)
.map(|idx| self.row_visual_rows_unified(idx))
.sum()
}
fn insert_draft_rows(&self, idx: usize) -> usize {
if self.mode == Mode::Insert
&& !self.comment_modal
&& idx == self.cursor_line
&& self.editing_comment.is_none()
{
self.input.matches('\n').count() + 1
} else {
0
}
}
fn row_visual_rows_split(&self, row: &crate::diff::SplitRow, side_width: usize) -> usize {
let Some(file) = self.current() else {
return 1;
};
let side_line_rows = |idx_opt: Option<usize>| -> usize {
idx_opt
.and_then(|i| file.lines.get(i))
.map(|l| line_visual_rows(l, side_width, SPLIT_LAYOUT))
.unwrap_or(1)
};
let mut count = side_line_rows(row.left).max(side_line_rows(row.right));
let side_comments = |idx_opt: Option<usize>, side: Side| -> Vec<&Comment> {
idx_opt
.map(|i| self.comments_on(file, i))
.unwrap_or_default()
.into_iter()
.filter(|c| c.side == side)
.collect()
};
let left_comments = side_comments(row.left, Side::Old);
let right_comments = side_comments(row.right, Side::New);
for ci in 0..left_comments.len().max(right_comments.len()) {
let lh = left_comments
.get(ci)
.map_or(0, |c| comment_plus_replies_rows(c));
let rh = right_comments
.get(ci)
.map_or(0, |c| comment_plus_replies_rows(c));
count += lh.max(rh);
}
count
}
fn visual_rows_split(
&self,
from_row: usize,
to_row: usize,
split_rows: &[crate::diff::SplitRow],
) -> usize {
if split_rows.is_empty() {
return 0;
}
let side_width = (self.diff_area.width as usize).saturating_sub(1) / 2;
let end = to_row.min(split_rows.len() - 1);
split_rows
.iter()
.take(end + 1)
.skip(from_row)
.map(|row| self.row_visual_rows_split(row, side_width))
.sum()
}
pub fn enter_insert(&mut self) {
if self.files.is_empty() || !self.is_code_line(self.cursor_line) {
return;
}
if let Some(cf) = self.comment_focus {
let comments = self
.current()
.map(|f| self.comments_on(f, self.cursor_line))
.unwrap_or_default();
if let Some(c) = comments.get(cf) {
self.input = c.body.clone();
self.input_cursor = self.input.len();
self.editing_comment = Some(cf);
self.mode = Mode::Insert;
return;
}
}
self.editing_comment = None;
self.mode = Mode::Insert;
self.input.clear();
self.input_cursor = 0;
}
pub fn cancel_insert(&mut self) {
self.reset_input(Mode::Normal);
self.editing_comment = None;
}
pub fn open_revision_selector(&mut self) {
self.revisions = crate::git::list_revisions();
self.revision_cursor = 0;
self.input.clear();
self.input_cursor = 0;
self.show_revision_selector = true;
}
pub fn close_revision_selector(&mut self) {
self.show_revision_selector = false;
self.input.clear();
self.input_cursor = 0;
}
pub fn filtered_revisions(&self) -> Vec<usize> {
if self.input.is_empty() {
return (0..self.revisions.len()).collect();
}
let q = self.input.to_lowercase();
self.revisions
.iter()
.enumerate()
.filter(|(_, r)| r.to_lowercase().contains(&q))
.map(|(i, _)| i)
.collect()
}
pub fn revision_selector_next(&mut self) {
let n = self.filtered_revisions().len();
if n == 0 {
return;
}
self.revision_cursor = (self.revision_cursor + 1) % n;
}
pub fn revision_selector_prev(&mut self) {
let n = self.filtered_revisions().len();
if n == 0 {
return;
}
if self.revision_cursor == 0 {
self.revision_cursor = n - 1;
} else {
self.revision_cursor -= 1;
}
}
pub fn confirm_revision_selector(&mut self) {
let filtered = self.filtered_revisions();
let Some(&idx) = filtered.get(self.revision_cursor) else {
return;
};
let target = self.revisions[idx]
.split('\t')
.next()
.unwrap_or(&self.revisions[idx])
.to_string();
self.pending_range_change = Some(target);
self.close_revision_selector();
}
pub fn on_revision_filter_change(&mut self) {
self.revision_cursor = 0;
}
fn cursor_anchor(&self) -> Option<(Side, u32)> {
line_anchor(self.current()?.lines.get(self.cursor_line)?)
}
pub fn submit_comment(&mut self) {
let body = self.input.trim().to_string();
if let Some(cf) = self.editing_comment {
let path = self.current_path();
let matching: Vec<usize> = self
.cursor_anchor()
.map(|(side, lineno)| {
self.comments
.iter()
.enumerate()
.filter(|(_, c)| c.anchored_at(&path, side, lineno))
.map(|(i, _)| i)
.collect()
})
.unwrap_or_default();
if let Some(&global_idx) = matching.get(cf) {
if body.is_empty() {
self.comments.remove(global_idx);
} else {
self.comments[global_idx].body = body;
}
}
} else if !body.is_empty() {
if let Some((side, lineno)) = self.cursor_anchor() {
let lineno_end = self.comment_line_end.take().and_then(|end_idx| {
let end_line = self.current()?.lines.get(end_idx)?;
match side {
Side::Old => end_line.old_lineno.or(end_line.new_lineno),
Side::New => end_line.new_lineno.or(end_line.old_lineno),
}
});
self.comments.push(Comment {
file: self.current_path(),
side,
lineno,
lineno_end,
body,
replies: Vec::new(),
});
}
}
self.comment_line_end = None;
self.cancel_insert();
}
fn jump_to_comment_line(&mut self, file_idx: usize, line: usize) {
self.selected = file_idx;
self.diff_scroll = 0;
self.cursor_line = line;
self.comment_focus = Some(0);
self.ensure_cursor_visible();
}
fn comment_display_indices_in_file(&self, file_idx: usize) -> Vec<usize> {
let Some(file) = self.files.get(file_idx) else {
return Vec::new();
};
let mut out: Vec<usize> = self
.comments
.iter()
.filter(|c| c.file == file.path)
.filter_map(|c| find_display_index(file, c.side, c.lineno))
.collect();
out.sort_unstable();
out.dedup();
out
}
pub fn next_comment(&mut self) {
let indices = self.comment_display_indices_in_file(self.selected);
if let Some(&line) = indices.iter().find(|&&i| i > self.cursor_line) {
self.jump_to_comment_line(self.selected, line);
return;
}
for offset in 1..=self.files.len() {
let idx = (self.selected + offset) % self.files.len();
let indices = self.comment_display_indices_in_file(idx);
if let Some(&line) = indices.first() {
self.jump_to_comment_line(idx, line);
return;
}
}
}
pub fn prev_comment(&mut self) {
let indices = self.comment_display_indices_in_file(self.selected);
if let Some(&line) = indices.iter().rev().find(|&&i| i < self.cursor_line) {
self.jump_to_comment_line(self.selected, line);
return;
}
for offset in 1..=self.files.len() {
let idx = (self.selected + self.files.len() - offset) % self.files.len();
let indices = self.comment_display_indices_in_file(idx);
if let Some(&line) = indices.last() {
self.jump_to_comment_line(idx, line);
return;
}
}
}
pub fn delete_focused_comment(&mut self) {
let Some(focus) = self.comment_focus else {
return;
};
let path = self.current_path();
let Some((side, lineno)) = self.cursor_anchor() else {
return;
};
let matching: Vec<usize> = self
.comments
.iter()
.enumerate()
.filter(|(_, c)| c.anchored_at(&path, side, lineno))
.map(|(i, _)| i)
.collect();
if matching.is_empty() {
return;
}
let focus_idx = focus.min(matching.len() - 1);
let global_idx = matching[focus_idx];
self.comments.remove(global_idx);
let remaining = matching.len() - 1;
if remaining == 0 {
self.comment_focus = None;
} else if focus_idx >= remaining {
self.comment_focus = Some(remaining - 1);
}
self.set_flash("Comment deleted".to_string());
}
pub fn toggle_viewed(&mut self) {
if let Some(file) = self.files.get_mut(self.selected) {
file.viewed = !file.viewed;
}
}
pub fn comments_on(&self, file: &FileDiff, line: usize) -> Vec<&Comment> {
let Some(diff_line) = file.lines.get(line) else {
return Vec::new();
};
let Some((side, lineno)) = line_anchor(diff_line) else {
return Vec::new();
};
self.comments
.iter()
.filter(|c| c.anchored_at(&file.path, side, lineno))
.collect()
}
fn clamp_comments_cursor(&mut self) {
if self.comments.is_empty() {
self.comments_list_cursor = 0;
} else if self.comments_list_cursor >= self.comments.len() {
self.comments_list_cursor = self.comments.len() - 1;
}
}
pub fn toggle_comments_list(&mut self) {
if self.comments.is_empty() {
return;
}
self.show_comments_list = !self.show_comments_list;
self.clamp_comments_cursor();
}
pub fn close_comments_list(&mut self) {
self.show_comments_list = false;
}
pub fn delete_selected_comment_in_list(&mut self) {
if self.comments_list_cursor >= self.comments.len() {
return;
}
self.comments.remove(self.comments_list_cursor);
if self.comments.is_empty() {
self.show_comments_list = false;
self.comments_list_cursor = 0;
} else {
if self.comments_list_cursor >= self.comments.len() {
self.comments_list_cursor = self.comments.len() - 1;
}
}
self.set_flash("Comment deleted".to_string());
}
pub fn selected_comment_in_list(&self) -> Option<&Comment> {
self.comments.get(self.comments_list_cursor)
}
pub fn comments_list_next(&mut self) {
self.comments_list_cursor = self.comments_list_cursor.saturating_add(1);
self.clamp_comments_cursor();
}
pub fn comments_list_prev(&mut self) {
self.comments_list_cursor = self.comments_list_cursor.saturating_sub(1);
}
pub fn jump_to_selected_comment(&mut self) {
let Some(comment) = self.comments.get(self.comments_list_cursor).cloned() else {
return;
};
if let Some(pos) = self.files.iter().position(|f| f.path == comment.file) {
self.selected = pos;
}
let total = self.total_lines();
if total == 0 {
self.show_comments_list = false;
return;
}
if let Some(file) = self.current() {
if let Some(idx) = find_display_index(file, comment.side, comment.lineno) {
self.cursor_line = idx;
} else {
self.cursor_line = self.cursor_line.min(total - 1);
}
}
let vh = self.viewport_height.max(1);
self.diff_scroll = self.cursor_line.saturating_sub(vh / 2);
self.show_comments_list = false;
}
fn reset_input(&mut self, mode: Mode) {
self.mode = mode;
self.input.clear();
self.input_cursor = 0;
}
pub fn enter_search(&mut self) {
self.reset_input(Mode::Search);
}
pub fn cancel_search(&mut self) {
self.reset_input(Mode::Normal);
}
pub fn submit_search(&mut self) {
let query = self.input.trim().to_string();
self.reset_input(Mode::Normal);
if query.is_empty() {
self.search_query = None;
self.search_matches.clear();
return;
}
self.search_query = Some(query.clone());
self.build_search_matches(&query);
self.search_match_cursor = 0;
self.jump_to_search_match();
}
fn build_search_matches(&mut self, query: &str) {
self.search_matches.clear();
let q = query.to_lowercase();
for (fi, file) in self.files.iter().enumerate() {
for (li, line) in file.lines.iter().enumerate() {
if line.kind.is_code() && line.text.to_lowercase().contains(&q) {
self.search_matches.push((fi, li));
}
}
}
}
pub fn next_search_match(&mut self) {
if self.search_matches.is_empty() {
return;
}
self.search_match_cursor = (self.search_match_cursor + 1) % self.search_matches.len();
self.jump_to_search_match();
}
pub fn prev_search_match(&mut self) {
if self.search_matches.is_empty() {
return;
}
if self.search_match_cursor == 0 {
self.search_match_cursor = self.search_matches.len() - 1;
} else {
self.search_match_cursor -= 1;
}
self.jump_to_search_match();
}
fn jump_to_search_match(&mut self) {
if let Some(&(fi, li)) = self.search_matches.get(self.search_match_cursor) {
self.selected = fi;
self.cursor_line = li;
self.comment_focus = None;
self.ensure_cursor_visible();
}
}
pub fn clear_search(&mut self) {
self.search_query = None;
self.search_matches.clear();
self.search_match_cursor = 0;
}
pub fn enter_file_filter(&mut self) {
self.reset_input(Mode::FileFilter);
}
pub fn cancel_file_filter(&mut self) {
self.file_filter = None;
self.reset_input(Mode::Normal);
}
pub fn submit_file_filter(&mut self) {
let query = self.input.trim().to_string();
self.reset_input(Mode::Normal);
if query.is_empty() {
self.file_filter = None;
} else {
self.file_filter = Some(query);
}
}
pub fn update_file_filter_live(&mut self) {
let query = self.input.trim().to_string();
if query.is_empty() {
self.file_filter = None;
} else {
self.file_filter = Some(query);
}
}
pub fn enter_visual(&mut self) {
if self.files.is_empty() || !self.is_code_line(self.cursor_line) {
return;
}
self.mode = Mode::Visual;
self.visual_start = Some(self.cursor_line);
self.comment_focus = None;
}
pub fn cancel_visual(&mut self) {
self.mode = Mode::Normal;
self.visual_start = None;
}
pub fn visual_range(&self) -> Option<(usize, usize)> {
self.visual_start.map(|start| {
let a = start.min(self.cursor_line);
let b = start.max(self.cursor_line);
(a, b)
})
}
pub fn is_in_visual(&self, idx: usize) -> bool {
if self.mode != Mode::Visual {
return false;
}
self.visual_range()
.map(|(a, b)| idx >= a && idx <= b)
.unwrap_or(false)
}
pub fn comment_display_range(
&self,
file: &FileDiff,
comment: &Comment,
) -> Option<(usize, usize)> {
let start = find_display_index(file, comment.side, comment.lineno)?;
let end = comment
.lineno_end
.and_then(|end| find_display_index(file, comment.side, end))
.unwrap_or(start);
let (a, b) = if start <= end {
(start, end)
} else {
(end, start)
};
Some((a, b))
}
pub fn underline_kind_for_side(
&self,
file: &FileDiff,
idx: usize,
side: Side,
) -> UnderlineKind {
let pending_side = || line_anchor(&file.lines[self.cursor_line]).map(|(s, _)| s);
if self.mode == Mode::Visual {
if let Some((a, b)) = self.visual_range()
&& idx >= a
&& idx <= b
&& pending_side() == Some(side)
{
return UnderlineKind::Bright;
}
} else if self.mode == Mode::Insert {
let bright = if let Some(cf) = self.editing_comment {
self.comments_on(file, self.cursor_line)
.get(cf)
.filter(|c| c.side == side)
.and_then(|c| self.comment_display_range(file, c))
} else if pending_side() == Some(side) {
let start = self.cursor_line;
let end = self.comment_line_end.unwrap_or(start);
Some((start.min(end), start.max(end)))
} else {
None
};
if let Some((a, b)) = bright
&& idx >= a
&& idx <= b
{
return UnderlineKind::Bright;
}
} else if let Some(cf) = self.comment_focus
&& let Some(c) = self
.comments_on(file, self.cursor_line)
.get(cf)
.filter(|c| c.side == side)
&& let Some((a, b)) = self.comment_display_range(file, c)
&& idx >= a
&& idx <= b
{
return UnderlineKind::Bright;
}
for c in &self.comments {
if c.file != file.path || c.side != side {
continue;
}
if let Some((a, b)) = self.comment_display_range(file, c)
&& idx >= a
&& idx <= b
{
return UnderlineKind::Dim;
}
}
UnderlineKind::None
}
pub fn underline_kind(&self, file: &FileDiff, idx: usize) -> UnderlineKind {
if self.mode == Mode::Visual {
if let Some((a, b)) = self.visual_range() {
if idx >= a && idx <= b {
return UnderlineKind::Bright;
}
}
} else if self.mode == Mode::Insert {
let bright_range = if let Some(cf) = self.editing_comment {
self.comments_on(file, self.cursor_line)
.get(cf)
.and_then(|c| self.comment_display_range(file, c))
} else {
let start = self.cursor_line;
let end = self.comment_line_end.unwrap_or(start);
Some((start.min(end), start.max(end)))
};
if let Some((a, b)) = bright_range {
if idx >= a && idx <= b {
return UnderlineKind::Bright;
}
}
} else if let Some(cf) = self.comment_focus {
if let Some(c) = self.comments_on(file, self.cursor_line).get(cf) {
if let Some((a, b)) = self.comment_display_range(file, c) {
if idx >= a && idx <= b {
return UnderlineKind::Bright;
}
}
}
}
for c in &self.comments {
if c.file != file.path {
continue;
}
if let Some((a, b)) = self.comment_display_range(file, c) {
if idx >= a && idx <= b {
return UnderlineKind::Dim;
}
}
}
UnderlineKind::None
}
pub fn submit_visual_comment(&mut self) {
if let Some((start, end)) = self.visual_range() {
self.cursor_line = end;
self.visual_start = None;
self.editing_comment = None;
self.mode = Mode::Insert;
self.input.clear();
self.input_cursor = 0;
if start == end {
self.comment_line_end = None;
} else {
self.comment_line_end = Some(start);
}
}
}
pub fn format_comment_full(&self, c: &Comment) -> String {
let mut out = format!("{}\n", c.location());
if let Some(file) = self.files.iter().find(|f| f.path == c.file) {
let start = c.lineno;
let end = c.lineno_end.unwrap_or(start);
let (lo, hi) = (start.min(end), start.max(end));
for line in &file.lines {
let Some((side, n)) = line_anchor(line) else {
continue;
};
if side == c.side && n >= lo && n <= hi {
out.push_str("> ");
out.push_str(&line.text);
out.push('\n');
}
}
}
out.push_str(&c.body);
for r in &c.replies {
out.push_str("\n↳ ");
out.push_str(r);
}
out
}
pub fn format_all_comments(&self) -> String {
self.comments
.iter()
.enumerate()
.map(|(i, c)| {
let prefix = if i > 0 { "=====\n" } else { "" };
format!("{}{}", prefix, self.format_comment_full(c))
})
.collect::<Vec<_>>()
.join("\n")
}
pub fn set_flash(&mut self, msg: String) {
if let Some(tx) = &self.action_tx {
let _ = tx.send(Action::Flash(msg));
}
}
pub fn input_insert_char(&mut self, c: char) {
self.input.insert(self.input_cursor, c);
self.input_cursor += c.len_utf8();
}
pub fn input_insert_newline(&mut self) {
self.input_insert_char('\n');
}
pub fn input_delete_backward(&mut self) {
if self.input_cursor == 0 {
return;
}
let prev = prev_char_boundary(&self.input, self.input_cursor);
self.input.drain(prev..self.input_cursor);
self.input_cursor = prev;
}
pub fn input_move_left(&mut self) {
if self.input_cursor > 0 {
self.input_cursor = prev_char_boundary(&self.input, self.input_cursor);
}
}
pub fn input_move_right(&mut self) {
if self.input_cursor < self.input.len() {
self.input_cursor = next_char_boundary(&self.input, self.input_cursor);
}
}
pub fn input_move_word_left(&mut self) {
let s = &self.input[..self.input_cursor];
let trimmed = s.trim_end_matches(|c: char| c.is_whitespace() && c != '\n');
let word_end = trimmed
.rfind(|c: char| c.is_whitespace() || c == '\n')
.map(|i| {
i + trimmed[i..]
.chars()
.next()
.map(|c| c.len_utf8())
.unwrap_or(0)
})
.unwrap_or(0);
self.input_cursor = word_end;
}
pub fn input_move_word_right(&mut self) {
let s = &self.input[self.input_cursor..];
let skip_word = s.find(|c: char| c.is_whitespace()).unwrap_or(s.len());
let skip_ws = s[skip_word..]
.find(|c: char| !c.is_whitespace())
.unwrap_or(s[skip_word..].len());
self.input_cursor += skip_word + skip_ws;
}
pub fn input_move_line_start(&mut self) {
let s = &self.input[..self.input_cursor];
self.input_cursor = s.rfind('\n').map(|i| i + 1).unwrap_or(0);
}
pub fn input_move_line_end(&mut self) {
let s = &self.input[self.input_cursor..];
self.input_cursor += s.find('\n').unwrap_or(s.len());
}
pub fn input_move_up(&mut self) {
let col = self.input_col();
self.input_move_line_start();
if self.input_cursor == 0 {
return;
}
self.input_cursor -= 1; self.input_move_line_start();
let line_len = self.input[self.input_cursor..]
.find('\n')
.unwrap_or(self.input.len() - self.input_cursor);
self.input_cursor = snap_back_to_boundary(
&self.input,
self.input_cursor + col.min(line_len),
self.input_cursor,
);
}
pub fn input_move_down(&mut self) {
let col = self.input_col();
self.input_move_line_end();
if self.input_cursor >= self.input.len() {
return;
}
self.input_cursor += 1; let line_start = self.input_cursor;
let line_len = self.input[line_start..]
.find('\n')
.unwrap_or(self.input.len() - line_start);
self.input_cursor =
snap_back_to_boundary(&self.input, line_start + col.min(line_len), line_start);
}
fn input_col(&self) -> usize {
let s = &self.input[..self.input_cursor];
self.input_cursor - s.rfind('\n').map(|i| i + 1).unwrap_or(0)
}
pub fn input_delete_word_backward(&mut self) {
let start = self.input_cursor;
self.input_move_word_left();
self.input.drain(self.input_cursor..start);
}
pub fn input_kill_to_end_of_line(&mut self) {
let end = self.input[self.input_cursor..]
.find('\n')
.map(|i| self.input_cursor + i)
.unwrap_or(self.input.len());
if end == self.input_cursor && end < self.input.len() {
self.input.remove(end);
} else {
self.input.drain(self.input_cursor..end);
}
}
pub fn input_clear(&mut self) {
self.input.clear();
self.input_cursor = 0;
}
}
fn prev_char_boundary(s: &str, from: usize) -> usize {
let mut i = from.saturating_sub(1);
while i > 0 && !s.is_char_boundary(i) {
i -= 1;
}
i
}
fn next_char_boundary(s: &str, from: usize) -> usize {
let mut i = from + 1;
while i < s.len() && !s.is_char_boundary(i) {
i += 1;
}
i.min(s.len())
}
fn snap_back_to_boundary(s: &str, target: usize, floor: usize) -> usize {
let mut i = target.min(s.len());
while i > floor && !s.is_char_boundary(i) {
i -= 1;
}
i
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::DiffRange;
use crate::diff::{DiffLine, FileDiff, FileStatus, LineKind};
fn make_app_with_input(input: &str, cursor: usize) -> App {
let file = FileDiff {
path: "f.txt".to_string(),
old_path: Some("f.txt".to_string()),
status: FileStatus::Modified,
added: 0,
removed: 0,
lines: vec![DiffLine::body(
LineKind::Context,
"line".to_string(),
Some(1),
Some(1),
)],
auto_collapsed: false,
viewed: false,
};
let mut app = App::new(vec![file], DiffRange::Working, ThemeName::default());
app.input = input.to_string();
app.input_cursor = cursor;
app
}
#[test]
fn input_move_down_over_multibyte_lands_on_char_boundary() {
let text = "abcd\n日本";
let mut app = make_app_with_input(text, 4);
app.input_move_down();
assert!(app.input.is_char_boundary(app.input_cursor));
assert_eq!(app.input_cursor, 8);
app.input_insert_char('x');
}
#[test]
fn input_move_up_over_multibyte_lands_on_char_boundary() {
let text = "日本\nxxxxxx";
let mut app = make_app_with_input(text, text.len());
app.input_move_up();
assert!(app.input.is_char_boundary(app.input_cursor));
assert_eq!(app.input_cursor, 6);
app.input_insert_char('z');
}
#[test]
fn input_move_down_at_last_line_lands_on_line_end() {
let mut app = make_app_with_input("one", 1);
app.input_move_down();
assert_eq!(app.input_cursor, 3);
}
#[test]
fn input_move_up_at_first_line_lands_on_line_start() {
let mut app = make_app_with_input("one", 2);
app.input_move_up();
assert_eq!(app.input_cursor, 0);
}
#[test]
fn input_move_word_right_skips_multibyte() {
let mut app = make_app_with_input("日本 世界", 0);
app.input_move_word_right();
assert!(app.input.is_char_boundary(app.input_cursor));
assert_eq!(&app.input[app.input_cursor..], "世界");
}
#[test]
fn input_delete_word_backward_preserves_boundary() {
let prefix = "fn 日本語";
let mut app = make_app_with_input("fn 日本語()", prefix.len());
app.input_delete_word_backward();
assert!(app.input.is_char_boundary(app.input_cursor));
assert_eq!(app.input, "fn ()");
}
fn make_app_with_lines(n: usize) -> App {
let lines: Vec<DiffLine> = (0..n)
.map(|i| {
DiffLine::body(
LineKind::Context,
format!("line {}", i),
Some(i as u32 + 1),
Some(i as u32 + 1),
)
})
.collect();
let file = FileDiff {
path: "f.txt".to_string(),
old_path: Some("f.txt".to_string()),
status: FileStatus::Modified,
added: 0,
removed: 0,
lines,
auto_collapsed: false,
viewed: false,
};
App::new(vec![file], DiffRange::Working, ThemeName::default())
}
#[test]
fn adding_comment_near_bottom_advances_scroll_to_show_whole_comment() {
let mut app = make_app_with_lines(30);
app.viewport_height = 10;
app.cursor_line = 29;
app.ensure_cursor_visible();
let scroll_before = app.diff_scroll;
app.comments.push(Comment {
file: "f.txt".to_string(),
side: Side::New,
lineno: 30,
lineno_end: None,
body: "l1\nl2\nl3\nl4\nl5".to_string(),
replies: Vec::new(),
});
app.ensure_cursor_visible();
assert!(
app.diff_scroll > scroll_before,
"scroll must advance when a comment is added at the bottom (before={}, after={})",
scroll_before,
app.diff_scroll,
);
let visual = app.visual_rows_unified(app.diff_scroll, app.cursor_line);
assert!(
visual <= app.viewport_height,
"comment must fit in viewport after scroll (visual={}, vh={})",
visual,
app.viewport_height,
);
}
#[test]
fn split_view_scrolls_enough_to_show_last_row_with_comments() {
let mut app = make_app_with_lines(30);
app.viewport_height = 10;
app.split_view = true;
app.cursor_line = 29;
app.comments.push(Comment {
file: "f.txt".to_string(),
side: Side::New,
lineno: 25,
lineno_end: None,
body: "heavy\ncomment\nbody".to_string(),
replies: Vec::new(),
});
app.ensure_cursor_visible();
let split_rows = crate::diff::build_split_rows(app.current().unwrap());
let scroll_row = split_rows
.iter()
.position(|r| r.left == Some(app.diff_scroll) || r.right == Some(app.diff_scroll))
.expect("diff_scroll must point at a split row");
let cursor_row = split_rows
.iter()
.position(|r| r.left == Some(app.cursor_line) || r.right == Some(app.cursor_line))
.expect("cursor must point at a split row");
let visible = app.visual_rows_split(scroll_row, cursor_row, &split_rows);
assert!(
visible <= app.viewport_height,
"cursor split row must fit in viewport (visible={}, vh={})",
visible,
app.viewport_height,
);
}
}