use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use chrono::Utc;
use ratatui::style::Color;
use crate::config::CommentTypeConfig;
use crate::error::{Result, TuicrError};
use crate::model::{
Comment, CommentType, DiffFile, DiffHunk, DiffLine, FileStatus, LineOrigin, LineRange,
LineSide, ReviewSession, SessionDiffSource,
};
use crate::persistence::load_latest_session_for_context;
use crate::syntax::SyntaxHighlighter;
use crate::theme::Theme;
use crate::update::UpdateInfo;
use crate::vcs::git::calculate_gap;
use crate::vcs::{CommitInfo, FileBackend, VcsBackend, VcsInfo, detect_vcs};
const VISIBLE_COMMIT_COUNT: usize = 10;
const COMMIT_PAGE_SIZE: usize = 10;
pub const STAGED_SELECTION_ID: &str = "__tuicr_staged__";
pub const UNSTAGED_SELECTION_ID: &str = "__tuicr_unstaged__";
pub const GAP_EXPAND_BATCH: usize = 20;
fn gap_annotation_line_count(is_top_of_file: bool, remaining: usize) -> usize {
if remaining == 0 {
0
} else if is_top_of_file {
if remaining > GAP_EXPAND_BATCH { 2 } else { 1 }
} else {
if remaining >= GAP_EXPAND_BATCH { 3 } else { 1 }
}
}
#[derive(Debug, Clone)]
pub enum FileTreeItem {
Directory {
path: String,
depth: usize,
expanded: bool,
},
File {
file_idx: usize,
depth: usize,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct GapId {
pub file_idx: usize,
pub hunk_idx: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ExpandDirection {
Down,
Up,
Both,
}
pub enum GapCursorHit {
Expander(GapId, ExpandDirection),
HiddenLines(GapId),
ExpandedContent(GapId),
}
#[derive(Debug, Clone)]
pub enum AnnotatedLine {
ReviewCommentsHeader,
ReviewComment { comment_idx: usize },
FileHeader { file_idx: usize },
FileComment { file_idx: usize, comment_idx: usize },
Expander {
gap_id: GapId,
direction: ExpandDirection,
},
HiddenLines { gap_id: GapId, count: usize },
ExpandedContext { gap_id: GapId, line_idx: usize },
HunkHeader { file_idx: usize, hunk_idx: usize },
DiffLine {
file_idx: usize,
hunk_idx: usize,
line_idx: usize,
old_lineno: Option<u32>,
new_lineno: Option<u32>,
},
SideBySideLine {
file_idx: usize,
hunk_idx: usize,
del_line_idx: Option<usize>,
add_line_idx: Option<usize>,
old_lineno: Option<u32>,
new_lineno: Option<u32>,
},
LineComment {
file_idx: usize,
line: u32,
side: LineSide,
comment_idx: usize,
},
BinaryOrEmpty { file_idx: usize },
Spacing,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FindSourceLineResult {
Exact(usize),
Nearest(usize),
NotFound,
}
pub fn find_source_line(
annotations: &[AnnotatedLine],
current_file: usize,
target_lineno: u32,
) -> FindSourceLineResult {
let mut best: Option<(usize, u32)> = None;
for (idx, annotation) in annotations.iter().enumerate() {
let (file_idx, new_lineno) = match annotation {
AnnotatedLine::DiffLine {
file_idx,
new_lineno,
..
} => (*file_idx, *new_lineno),
AnnotatedLine::SideBySideLine {
file_idx,
new_lineno,
..
} => (*file_idx, *new_lineno),
_ => continue,
};
if file_idx != current_file {
continue;
}
if let Some(ln) = new_lineno {
let dist = ln.abs_diff(target_lineno);
if dist == 0 {
return FindSourceLineResult::Exact(idx);
}
if best.is_none() || dist < best.unwrap().1 {
best = Some((idx, dist));
}
}
}
match best {
Some((idx, _)) => FindSourceLineResult::Nearest(idx),
None => FindSourceLineResult::NotFound,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InputMode {
Normal,
Comment,
Command,
Search,
Help,
Confirm,
CommitSelect,
VisualSelect,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DiffSource {
WorkingTree,
Staged,
Unstaged,
StagedAndUnstaged,
CommitRange(Vec<String>),
StagedUnstagedAndCommits(Vec<String>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfirmAction {
CopyAndQuit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FocusedPanel {
FileList,
Diff,
CommitSelector,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffViewMode {
Unified,
SideBySide,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MessageType {
Info,
Warning,
Error,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Message {
pub content: String,
pub message_type: MessageType,
}
pub struct App {
pub theme: Theme,
pub vcs: Box<dyn VcsBackend>,
pub vcs_info: VcsInfo,
pub session: ReviewSession,
pub diff_files: Vec<DiffFile>,
pub diff_source: DiffSource,
pub input_mode: InputMode,
pub focused_panel: FocusedPanel,
pub diff_view_mode: DiffViewMode,
pub file_list_state: FileListState,
pub diff_state: DiffState,
pub help_state: HelpState,
pub command_buffer: String,
pub search_buffer: String,
pub last_search_pattern: Option<String>,
pub comment_buffer: String,
pub comment_cursor: usize,
pub comment_type: CommentType,
pub comment_types: Vec<CommentTypeDefinition>,
pub comment_is_review_level: bool,
pub comment_is_file_level: bool,
pub comment_line: Option<(u32, LineSide)>,
pub editing_comment_id: Option<String>,
pub visual_anchor: Option<(u32, LineSide)>,
pub comment_line_range: Option<(LineRange, LineSide)>,
pub commit_list: Vec<CommitInfo>,
pub commit_list_cursor: usize,
pub commit_list_scroll_offset: usize,
pub commit_list_viewport_height: usize,
pub commit_selection_range: Option<(usize, usize)>,
pub visible_commit_count: usize,
pub commit_page_size: usize,
pub has_more_commit: bool,
pub should_quit: bool,
pub dirty: bool,
pub quit_warned: bool,
pub message: Option<Message>,
pub pending_confirm: Option<ConfirmAction>,
pub supports_keyboard_enhancement: bool,
pub show_file_list: bool,
pub file_list_area: Option<ratatui::layout::Rect>,
pub diff_area: Option<ratatui::layout::Rect>,
pub expanded_dirs: HashSet<String>,
pub expanded_top: HashMap<GapId, Vec<DiffLine>>,
pub expanded_bottom: HashMap<GapId, Vec<DiffLine>>,
pub line_annotations: Vec<AnnotatedLine>,
pub output_to_stdout: bool,
pub pending_stdout_output: Option<String>,
pub comment_cursor_screen_pos: Option<(u16, u16)>,
pub update_info: Option<UpdateInfo>,
pub pending_count: Option<usize>,
pub review_commits: Vec<CommitInfo>,
pub show_commit_selector: bool,
pub commit_diff_cache: HashMap<(usize, usize), Vec<DiffFile>>,
pub range_diff_files: Option<Vec<DiffFile>>,
pub saved_inline_selection: Option<(usize, usize)>,
pub path_filter: Option<String>,
pub export_legend: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommentTypeDefinition {
pub id: String,
pub label: String,
pub definition: Option<String>,
pub color: Option<Color>,
}
#[derive(Default)]
pub struct FileListState {
pub list_state: ratatui::widgets::ListState,
pub scroll_x: usize,
pub viewport_width: usize, pub viewport_height: usize, pub max_content_width: usize, }
impl FileListState {
pub fn selected(&self) -> usize {
self.list_state.selected().unwrap_or(0)
}
pub fn select(&mut self, index: usize) {
self.list_state.select(Some(index));
}
pub fn scroll_left(&mut self, cols: usize) {
self.scroll_x = self.scroll_x.saturating_sub(cols);
}
pub fn scroll_right(&mut self, cols: usize) {
let max_scroll_x = self.max_content_width.saturating_sub(self.viewport_width);
self.scroll_x = (self.scroll_x.saturating_add(cols)).min(max_scroll_x);
}
}
#[derive(Debug)]
pub struct DiffState {
pub scroll_offset: usize,
pub scroll_x: usize,
pub cursor_line: usize,
pub current_file_idx: usize,
pub viewport_height: usize,
pub viewport_width: usize,
pub max_content_width: usize,
pub wrap_lines: bool,
pub visible_line_count: usize,
}
impl Default for DiffState {
fn default() -> Self {
Self {
scroll_offset: 0,
scroll_x: 0,
cursor_line: 0,
current_file_idx: 0,
viewport_height: 0,
viewport_width: 0,
max_content_width: 0,
wrap_lines: true,
visible_line_count: 0,
}
}
}
#[derive(Debug, Default)]
pub struct HelpState {
pub scroll_offset: usize,
pub viewport_height: usize,
pub total_lines: usize, }
enum CommentLocation {
Review {
index: usize,
},
File {
path: std::path::PathBuf,
index: usize,
},
Line {
path: std::path::PathBuf,
line: u32,
side: LineSide,
index: usize,
},
}
impl App {
pub fn new(
theme: Theme,
comment_type_configs: Option<Vec<CommentTypeConfig>>,
output_to_stdout: bool,
revisions: Option<&str>,
working_tree: bool,
path_filter: Option<&str>,
file_path: Option<&str>,
) -> Result<Self> {
if let Some(file_path) = file_path {
let vcs = Box::new(FileBackend::new(file_path)?);
let vcs_info = vcs.info().clone();
let highlighter = theme.syntax_highlighter();
let diff_files = vcs.get_working_tree_diff(highlighter)?;
let session = Self::load_or_create_session(&vcs_info, SessionDiffSource::WorkingTree);
let mut app = Self::build(
vcs,
vcs_info,
theme,
comment_type_configs,
output_to_stdout,
diff_files,
session,
DiffSource::WorkingTree,
InputMode::Normal,
Vec::new(),
None, )?;
app.show_file_list = false;
app.focused_panel = FocusedPanel::Diff;
return Ok(app);
}
let vcs = detect_vcs()?;
let vcs_info = vcs.info().clone();
let highlighter = theme.syntax_highlighter();
if let Some(revisions) = revisions {
let commit_ids = vcs.resolve_revisions(revisions)?;
if working_tree {
let diff_files = Self::get_working_tree_with_commits_diff_with_ignore(
vcs.as_ref(),
&vcs_info.root_path,
&commit_ids,
highlighter,
path_filter,
)?;
let session = Self::load_or_create_staged_unstaged_and_commits_session(
&vcs_info,
&commit_ids,
);
let review_commits: Vec<CommitInfo> = vcs
.get_commits_info(&commit_ids)?
.into_iter()
.rev()
.collect();
let has_staged = Self::get_staged_diff_with_ignore(
vcs.as_ref(),
&vcs_info.root_path,
highlighter,
path_filter,
)
.is_ok();
let has_unstaged = Self::get_unstaged_diff_with_ignore(
vcs.as_ref(),
&vcs_info.root_path,
highlighter,
path_filter,
)
.is_ok();
let mut all_commits = Vec::new();
if has_staged {
all_commits.push(Self::staged_commit_entry());
}
if has_unstaged {
all_commits.push(Self::unstaged_commit_entry());
}
all_commits.extend(review_commits);
let mut app = Self::build(
vcs,
vcs_info,
theme,
comment_type_configs.clone(),
output_to_stdout,
diff_files,
session,
DiffSource::StagedUnstagedAndCommits(commit_ids),
InputMode::Normal,
Vec::new(),
path_filter,
)?;
app.range_diff_files = Some(app.diff_files.clone());
app.commit_list = all_commits.clone();
app.commit_list_cursor = 0;
app.commit_selection_range = if all_commits.is_empty() {
None
} else {
Some((0, all_commits.len() - 1))
};
app.commit_list_scroll_offset = 0;
app.visible_commit_count = all_commits.len();
app.has_more_commit = false;
app.show_commit_selector = all_commits.len() > 1;
app.commit_diff_cache.clear();
app.review_commits = all_commits;
app.insert_commit_message_if_single();
app.sort_files_by_directory(true);
app.expand_all_dirs();
app.rebuild_annotations();
return Ok(app);
}
let diff_files = Self::get_commit_range_diff_with_ignore(
vcs.as_ref(),
&vcs_info.root_path,
&commit_ids,
highlighter,
path_filter,
)?;
let session = Self::load_or_create_commit_range_session(&vcs_info, &commit_ids);
let review_commits = vcs.get_commits_info(&commit_ids)?;
let review_commits: Vec<CommitInfo> = review_commits.into_iter().rev().collect();
let mut app = Self::build(
vcs,
vcs_info,
theme,
comment_type_configs.clone(),
output_to_stdout,
diff_files,
session,
DiffSource::CommitRange(commit_ids),
InputMode::Normal,
Vec::new(),
path_filter,
)?;
if review_commits.len() > 1 {
app.range_diff_files = Some(app.diff_files.clone());
app.commit_list = review_commits.clone();
app.commit_list_cursor = 0;
app.commit_selection_range = Some((0, review_commits.len() - 1));
app.commit_list_scroll_offset = 0;
app.visible_commit_count = review_commits.len();
app.has_more_commit = false;
app.show_commit_selector = true;
app.commit_diff_cache.clear();
}
app.review_commits = review_commits;
app.insert_commit_message_if_single();
app.sort_files_by_directory(true);
app.expand_all_dirs();
app.rebuild_annotations();
Ok(app)
} else if working_tree {
let diff_files = Self::get_working_tree_diff_with_ignore(
vcs.as_ref(),
&vcs_info.root_path,
highlighter,
path_filter,
)?;
let session =
Self::load_or_create_session(&vcs_info, SessionDiffSource::StagedAndUnstaged);
let app = Self::build(
vcs,
vcs_info,
theme,
comment_type_configs,
output_to_stdout,
diff_files,
session,
DiffSource::StagedAndUnstaged,
InputMode::Normal,
Vec::new(),
path_filter,
)?;
Ok(app)
} else {
let has_staged_changes = match Self::get_staged_diff_with_ignore(
vcs.as_ref(),
&vcs_info.root_path,
highlighter,
path_filter,
) {
Ok(_) => true,
Err(TuicrError::NoChanges) => false,
Err(TuicrError::UnsupportedOperation(_)) => false,
Err(e) => return Err(e),
};
let has_unstaged_changes = match Self::get_unstaged_diff_with_ignore(
vcs.as_ref(),
&vcs_info.root_path,
highlighter,
path_filter,
) {
Ok(_) => true,
Err(TuicrError::NoChanges) => false,
Err(TuicrError::UnsupportedOperation(_)) => false,
Err(e) => return Err(e),
};
let working_tree_diff = if has_staged_changes || has_unstaged_changes {
match Self::get_working_tree_diff_with_ignore(
vcs.as_ref(),
&vcs_info.root_path,
highlighter,
path_filter,
) {
Ok(diff_files) => Some(diff_files),
Err(TuicrError::NoChanges) => None,
Err(e) => return Err(e),
}
} else {
None
};
let commits = vcs.get_recent_commits(0, VISIBLE_COMMIT_COUNT)?;
if !has_staged_changes && !has_unstaged_changes && commits.is_empty() {
return Err(TuicrError::NoChanges);
}
let mut commit_list = commits.clone();
if has_staged_changes {
commit_list.insert(0, Self::staged_commit_entry());
}
if has_unstaged_changes {
commit_list.insert(0, Self::unstaged_commit_entry());
}
let diff_source = if has_staged_changes && has_unstaged_changes {
DiffSource::StagedAndUnstaged
} else if has_staged_changes {
DiffSource::Staged
} else if has_unstaged_changes {
DiffSource::Unstaged
} else {
DiffSource::WorkingTree
};
let session_source = if has_staged_changes && has_unstaged_changes {
SessionDiffSource::StagedAndUnstaged
} else if has_staged_changes {
SessionDiffSource::Staged
} else if has_unstaged_changes {
SessionDiffSource::Unstaged
} else {
SessionDiffSource::WorkingTree
};
let session = Self::load_or_create_session(&vcs_info, session_source);
let mut app = Self::build(
vcs,
vcs_info,
theme,
comment_type_configs,
output_to_stdout,
working_tree_diff.unwrap_or_default(),
session,
diff_source,
InputMode::CommitSelect,
commit_list,
path_filter,
)?;
app.has_more_commit = commits.len() >= VISIBLE_COMMIT_COUNT;
app.visible_commit_count = app.commit_list.len();
Ok(app)
}
}
#[allow(clippy::too_many_arguments)]
fn build(
vcs: Box<dyn VcsBackend>,
vcs_info: VcsInfo,
theme: Theme,
comment_type_configs: Option<Vec<CommentTypeConfig>>,
output_to_stdout: bool,
diff_files: Vec<DiffFile>,
mut session: ReviewSession,
diff_source: DiffSource,
input_mode: InputMode,
commit_list: Vec<CommitInfo>,
path_filter: Option<&str>,
) -> Result<Self> {
for file in &diff_files {
session.add_file(file.display_path().clone(), file.status);
}
let has_more_commit = commit_list.len() >= VISIBLE_COMMIT_COUNT;
let visible_commit_count = if commit_list.is_empty() {
VISIBLE_COMMIT_COUNT
} else {
commit_list.len()
};
let comment_types = Self::resolve_comment_types(&theme, comment_type_configs);
let default_comment_type = Self::first_comment_type(&comment_types);
let mut app = Self {
theme,
vcs,
vcs_info,
session,
diff_files,
diff_source,
input_mode,
focused_panel: FocusedPanel::Diff,
diff_view_mode: DiffViewMode::Unified,
file_list_state: FileListState::default(),
diff_state: DiffState::default(),
help_state: HelpState::default(),
command_buffer: String::new(),
search_buffer: String::new(),
last_search_pattern: None,
comment_buffer: String::new(),
comment_cursor: 0,
comment_type: default_comment_type,
comment_types,
comment_is_review_level: false,
comment_is_file_level: true,
comment_line: None,
editing_comment_id: None,
visual_anchor: None,
comment_line_range: None,
commit_list,
commit_list_cursor: 0,
commit_list_scroll_offset: 0,
commit_list_viewport_height: 0,
commit_selection_range: None,
visible_commit_count,
commit_page_size: COMMIT_PAGE_SIZE,
has_more_commit,
should_quit: false,
dirty: false,
quit_warned: false,
message: None,
pending_confirm: None,
supports_keyboard_enhancement: false,
show_file_list: true,
file_list_area: None,
diff_area: None,
expanded_dirs: HashSet::new(),
expanded_top: HashMap::new(),
expanded_bottom: HashMap::new(),
line_annotations: Vec::new(),
output_to_stdout,
pending_stdout_output: None,
comment_cursor_screen_pos: None,
update_info: None,
pending_count: None,
review_commits: Vec::new(),
show_commit_selector: false,
commit_diff_cache: HashMap::new(),
range_diff_files: None,
saved_inline_selection: None,
path_filter: path_filter.map(|s| s.to_string()),
export_legend: true,
};
if app.path_filter.is_some() && app.diff_files.len() == 1 {
app.show_file_list = false;
app.focused_panel = FocusedPanel::Diff;
}
app.sort_files_by_directory(true);
app.expand_all_dirs();
app.rebuild_annotations();
Ok(app)
}
fn resolve_comment_types(
theme: &Theme,
comment_type_configs: Option<Vec<CommentTypeConfig>>,
) -> Vec<CommentTypeDefinition> {
let defaults = vec![
CommentTypeDefinition {
id: "note".to_string(),
label: "note".to_string(),
definition: Some("observations".to_string()),
color: Some(theme.comment_note),
},
CommentTypeDefinition {
id: "suggestion".to_string(),
label: "suggestion".to_string(),
definition: Some("improvements".to_string()),
color: Some(theme.comment_suggestion),
},
CommentTypeDefinition {
id: "issue".to_string(),
label: "issue".to_string(),
definition: Some("problems to fix".to_string()),
color: Some(theme.comment_issue),
},
CommentTypeDefinition {
id: "praise".to_string(),
label: "praise".to_string(),
definition: Some("positive feedback".to_string()),
color: Some(theme.comment_praise),
},
];
let Some(configs) = comment_type_configs else {
return defaults;
};
let mut resolved = Vec::new();
for config in configs {
let id = config.id;
let label = config.label.unwrap_or_else(|| id.clone());
let definition = config.definition;
let color = config.color.as_deref().and_then(Self::parse_config_color);
resolved.push(CommentTypeDefinition {
id,
label,
definition,
color,
});
}
if resolved.is_empty() {
defaults
} else {
resolved
}
}
fn first_comment_type(comment_types: &[CommentTypeDefinition]) -> CommentType {
comment_types
.first()
.map(|comment_type| CommentType::from_id(&comment_type.id))
.unwrap_or_default()
}
fn default_comment_type(&self) -> CommentType {
Self::first_comment_type(&self.comment_types)
}
fn parse_config_color(value: &str) -> Option<Color> {
let normalized = value.trim().to_ascii_lowercase();
if normalized.is_empty() {
return None;
}
if let Some(hex) = normalized.strip_prefix('#')
&& hex.len() == 6
&& let Ok(rgb) = u32::from_str_radix(hex, 16)
{
let r = ((rgb >> 16) & 0xff) as u8;
let g = ((rgb >> 8) & 0xff) as u8;
let b = (rgb & 0xff) as u8;
return Some(Color::Rgb(r, g, b));
}
match normalized.as_str() {
"black" => Some(Color::Black),
"red" => Some(Color::Red),
"green" => Some(Color::Green),
"yellow" => Some(Color::Yellow),
"blue" => Some(Color::Blue),
"magenta" => Some(Color::Magenta),
"cyan" => Some(Color::Cyan),
"gray" | "grey" => Some(Color::Gray),
"darkgray" | "dark_gray" | "darkgrey" | "dark_grey" => Some(Color::DarkGray),
"lightred" | "light_red" => Some(Color::LightRed),
"lightgreen" | "light_green" => Some(Color::LightGreen),
"lightyellow" | "light_yellow" => Some(Color::LightYellow),
"lightblue" | "light_blue" => Some(Color::LightBlue),
"lightmagenta" | "light_magenta" => Some(Color::LightMagenta),
"lightcyan" | "light_cyan" => Some(Color::LightCyan),
"white" => Some(Color::White),
_ => None,
}
}
pub fn comment_type_label(&self, comment_type: &CommentType) -> String {
if let Some(definition) = self
.comment_types
.iter()
.find(|definition| definition.id == comment_type.id())
{
return definition.label.to_ascii_uppercase();
}
comment_type.as_str()
}
pub fn comment_type_color(&self, comment_type: &CommentType) -> Color {
if let Some(definition) = self
.comment_types
.iter()
.find(|definition| definition.id == comment_type.id())
&& let Some(color) = definition.color
{
return color;
}
match comment_type.id() {
"note" => self.theme.comment_note,
"suggestion" => self.theme.comment_suggestion,
"issue" => self.theme.comment_issue,
"praise" => self.theme.comment_praise,
_ => self.theme.fg_secondary,
}
}
fn load_or_create_commit_range_session(
vcs_info: &VcsInfo,
commit_ids: &[String],
) -> ReviewSession {
let newest_commit_id = commit_ids.last().unwrap().clone();
let loaded = load_latest_session_for_context(
&vcs_info.root_path,
vcs_info.branch_name.as_deref(),
&newest_commit_id,
SessionDiffSource::CommitRange,
Some(commit_ids),
)
.ok()
.and_then(|found| found.map(|(_path, session)| session));
let mut session = loaded.unwrap_or_else(|| {
let mut s = ReviewSession::new(
vcs_info.root_path.clone(),
newest_commit_id,
vcs_info.branch_name.clone(),
SessionDiffSource::CommitRange,
);
s.commit_range = Some(commit_ids.to_vec());
s
});
if session.commit_range.is_none() {
session.commit_range = Some(commit_ids.to_vec());
session.updated_at = chrono::Utc::now();
}
session
}
fn load_or_create_staged_unstaged_and_commits_session(
vcs_info: &VcsInfo,
commit_ids: &[String],
) -> ReviewSession {
let newest_commit_id = commit_ids.last().unwrap().clone();
let loaded = load_latest_session_for_context(
&vcs_info.root_path,
vcs_info.branch_name.as_deref(),
&newest_commit_id,
SessionDiffSource::StagedUnstagedAndCommits,
Some(commit_ids),
)
.ok()
.and_then(|found| found.map(|(_path, session)| session));
let mut session = loaded.unwrap_or_else(|| {
let mut s = ReviewSession::new(
vcs_info.root_path.clone(),
newest_commit_id,
vcs_info.branch_name.clone(),
SessionDiffSource::StagedUnstagedAndCommits,
);
s.commit_range = Some(commit_ids.to_vec());
s
});
if session.commit_range.is_none() {
session.commit_range = Some(commit_ids.to_vec());
session.updated_at = chrono::Utc::now();
}
session
}
fn load_or_create_session(vcs_info: &VcsInfo, diff_source: SessionDiffSource) -> ReviewSession {
let new_session = || {
ReviewSession::new(
vcs_info.root_path.clone(),
vcs_info.head_commit.clone(),
vcs_info.branch_name.clone(),
diff_source,
)
};
let Ok(found) = load_latest_session_for_context(
&vcs_info.root_path,
vcs_info.branch_name.as_deref(),
&vcs_info.head_commit,
diff_source,
None,
) else {
return new_session();
};
let Some((_path, mut session)) = found else {
return new_session();
};
let mut updated = false;
if session.branch_name.is_none() && vcs_info.branch_name.is_some() {
session.branch_name = vcs_info.branch_name.clone();
updated = true;
}
if vcs_info.branch_name.is_some() && session.base_commit != vcs_info.head_commit {
session.base_commit = vcs_info.head_commit.clone();
updated = true;
}
if updated {
session.updated_at = chrono::Utc::now();
}
session
}
fn staged_commit_entry() -> CommitInfo {
CommitInfo {
id: STAGED_SELECTION_ID.to_string(),
short_id: "STAGED".to_string(),
branch_name: None,
summary: "Staged changes".to_string(),
body: None,
author: String::new(),
time: Utc::now(),
}
}
fn unstaged_commit_entry() -> CommitInfo {
CommitInfo {
id: UNSTAGED_SELECTION_ID.to_string(),
short_id: "UNSTAGED".to_string(),
branch_name: None,
summary: "Unstaged changes".to_string(),
body: None,
author: String::new(),
time: Utc::now(),
}
}
fn insert_commit_message_if_single(&mut self) {
self.diff_files.retain(|f| !f.is_commit_message);
let commit = if let Some((start, end)) = self.commit_selection_range {
if start == end {
self.review_commits.get(start)
} else {
None
}
} else if self.review_commits.len() == 1 {
self.review_commits.first()
} else {
None
};
let Some(commit) = commit else { return };
if Self::is_special_commit(commit) {
return;
}
let mut full_message = commit.summary.clone();
if let Some(ref body) = commit.body {
full_message.push('\n');
full_message.push('\n');
full_message.push_str(body);
}
let diff_lines: Vec<DiffLine> = full_message
.lines()
.enumerate()
.map(|(i, line)| DiffLine {
origin: LineOrigin::Context,
content: line.to_string(),
old_lineno: None,
new_lineno: Some(i as u32 + 1),
highlighted_spans: None,
})
.collect();
let line_count = diff_lines.len() as u32;
let commit_msg_file = DiffFile {
old_path: None,
new_path: Some(PathBuf::from("Commit Message")),
status: FileStatus::Added,
hunks: vec![DiffHunk {
header: String::new(),
lines: diff_lines,
old_start: 0,
old_count: 0,
new_start: 1,
new_count: line_count,
}],
is_binary: false,
is_too_large: false,
is_commit_message: true,
};
self.diff_files.insert(0, commit_msg_file);
self.session
.add_file(PathBuf::from("Commit Message"), FileStatus::Added);
}
fn is_staged_commit(commit: &CommitInfo) -> bool {
commit.id == STAGED_SELECTION_ID
}
fn is_unstaged_commit(commit: &CommitInfo) -> bool {
commit.id == UNSTAGED_SELECTION_ID
}
fn is_special_commit(commit: &CommitInfo) -> bool {
Self::is_staged_commit(commit) || Self::is_unstaged_commit(commit)
}
fn special_commit_count(&self) -> usize {
self.commit_list
.iter()
.take_while(|commit| Self::is_special_commit(commit))
.count()
}
fn loaded_history_commit_count(&self) -> usize {
self.commit_list
.len()
.saturating_sub(self.special_commit_count())
}
fn filter_ignored_diff_files(repo_root: &Path, diff_files: Vec<DiffFile>) -> Vec<DiffFile> {
crate::tuicrignore::filter_diff_files(repo_root, diff_files)
}
fn filter_by_path(diff_files: Vec<DiffFile>, path: &str) -> Vec<DiffFile> {
let path = path.trim_end_matches('/');
diff_files
.into_iter()
.filter(|f| {
let display = f.display_path().to_string_lossy();
display == path || display.starts_with(&format!("{path}/"))
})
.collect()
}
fn require_non_empty_diff_files(diff_files: Vec<DiffFile>) -> Result<Vec<DiffFile>> {
if diff_files.is_empty() {
return Err(TuicrError::NoChanges);
}
Ok(diff_files)
}
fn get_working_tree_diff_with_ignore(
vcs: &dyn VcsBackend,
repo_root: &Path,
highlighter: &SyntaxHighlighter,
path_filter: Option<&str>,
) -> Result<Vec<DiffFile>> {
let diff_files = vcs.get_working_tree_diff(highlighter)?;
let diff_files = Self::filter_ignored_diff_files(repo_root, diff_files);
let diff_files = if let Some(path) = path_filter {
Self::filter_by_path(diff_files, path)
} else {
diff_files
};
Self::require_non_empty_diff_files(diff_files)
}
fn get_staged_diff_with_ignore(
vcs: &dyn VcsBackend,
repo_root: &Path,
highlighter: &SyntaxHighlighter,
path_filter: Option<&str>,
) -> Result<Vec<DiffFile>> {
let diff_files = vcs.get_staged_diff(highlighter)?;
let diff_files = Self::filter_ignored_diff_files(repo_root, diff_files);
let diff_files = if let Some(path) = path_filter {
Self::filter_by_path(diff_files, path)
} else {
diff_files
};
Self::require_non_empty_diff_files(diff_files)
}
fn get_unstaged_diff_with_ignore(
vcs: &dyn VcsBackend,
repo_root: &Path,
highlighter: &SyntaxHighlighter,
path_filter: Option<&str>,
) -> Result<Vec<DiffFile>> {
let diff_files = match vcs.get_unstaged_diff(highlighter) {
Ok(diff_files) => diff_files,
Err(TuicrError::UnsupportedOperation(_)) => vcs.get_working_tree_diff(highlighter)?,
Err(e) => return Err(e),
};
let diff_files = Self::filter_ignored_diff_files(repo_root, diff_files);
let diff_files = if let Some(path) = path_filter {
Self::filter_by_path(diff_files, path)
} else {
diff_files
};
Self::require_non_empty_diff_files(diff_files)
}
fn get_commit_range_diff_with_ignore(
vcs: &dyn VcsBackend,
repo_root: &Path,
commit_ids: &[String],
highlighter: &SyntaxHighlighter,
path_filter: Option<&str>,
) -> Result<Vec<DiffFile>> {
let diff_files = vcs.get_commit_range_diff(commit_ids, highlighter)?;
let diff_files = Self::filter_ignored_diff_files(repo_root, diff_files);
let diff_files = if let Some(path) = path_filter {
Self::filter_by_path(diff_files, path)
} else {
diff_files
};
Self::require_non_empty_diff_files(diff_files)
}
fn get_working_tree_with_commits_diff_with_ignore(
vcs: &dyn VcsBackend,
repo_root: &Path,
commit_ids: &[String],
highlighter: &SyntaxHighlighter,
path_filter: Option<&str>,
) -> Result<Vec<DiffFile>> {
let diff_files = vcs.get_working_tree_with_commits_diff(commit_ids, highlighter)?;
let diff_files = Self::filter_ignored_diff_files(repo_root, diff_files);
let diff_files = if let Some(path) = path_filter {
Self::filter_by_path(diff_files, path)
} else {
diff_files
};
Self::require_non_empty_diff_files(diff_files)
}
fn load_staged_and_unstaged_selection(&mut self) -> Result<()> {
let highlighter = self.theme.syntax_highlighter();
let diff_files = match Self::get_working_tree_diff_with_ignore(
self.vcs.as_ref(),
&self.vcs_info.root_path,
highlighter,
self.path_filter.as_deref(),
) {
Ok(diff_files) => diff_files,
Err(TuicrError::NoChanges) => {
self.set_message("No staged or unstaged changes");
return Ok(());
}
Err(e) => return Err(e),
};
self.session =
Self::load_or_create_session(&self.vcs_info, SessionDiffSource::StagedAndUnstaged);
for file in &diff_files {
let path = file.display_path().clone();
self.session.add_file(path, file.status);
}
self.diff_files = diff_files;
self.diff_source = DiffSource::StagedAndUnstaged;
self.input_mode = InputMode::Normal;
self.diff_state = DiffState::default();
self.file_list_state = FileListState::default();
self.clear_expanded_gaps();
self.sort_files_by_directory(true);
self.expand_all_dirs();
self.rebuild_annotations();
Ok(())
}
fn load_staged_selection(&mut self) -> Result<()> {
let highlighter = self.theme.syntax_highlighter();
let diff_files = match Self::get_staged_diff_with_ignore(
self.vcs.as_ref(),
&self.vcs_info.root_path,
highlighter,
self.path_filter.as_deref(),
) {
Ok(diff_files) => diff_files,
Err(TuicrError::NoChanges) => {
self.set_message("No staged changes");
return Ok(());
}
Err(e) => return Err(e),
};
self.session = Self::load_or_create_session(&self.vcs_info, SessionDiffSource::Staged);
for file in &diff_files {
let path = file.display_path().clone();
self.session.add_file(path, file.status);
}
self.diff_files = diff_files;
self.diff_source = DiffSource::Staged;
self.input_mode = InputMode::Normal;
self.diff_state = DiffState::default();
self.file_list_state = FileListState::default();
self.clear_expanded_gaps();
self.sort_files_by_directory(true);
self.expand_all_dirs();
self.rebuild_annotations();
Ok(())
}
fn load_unstaged_selection(&mut self) -> Result<()> {
let highlighter = self.theme.syntax_highlighter();
let diff_files = match Self::get_unstaged_diff_with_ignore(
self.vcs.as_ref(),
&self.vcs_info.root_path,
highlighter,
self.path_filter.as_deref(),
) {
Ok(diff_files) => diff_files,
Err(TuicrError::NoChanges) => {
self.set_message("No unstaged changes");
return Ok(());
}
Err(e) => return Err(e),
};
self.session = Self::load_or_create_session(&self.vcs_info, SessionDiffSource::Unstaged);
for file in &diff_files {
let path = file.display_path().clone();
self.session.add_file(path, file.status);
}
self.diff_files = diff_files;
self.diff_source = DiffSource::Unstaged;
self.input_mode = InputMode::Normal;
self.diff_state = DiffState::default();
self.file_list_state = FileListState::default();
self.clear_expanded_gaps();
self.sort_files_by_directory(true);
self.expand_all_dirs();
self.rebuild_annotations();
Ok(())
}
pub fn reload_diff_files(&mut self) -> Result<usize> {
let current_path = self.current_file_path().cloned();
let prev_file_idx = self.diff_state.current_file_idx;
let prev_cursor_line = self.diff_state.cursor_line;
let prev_viewport_offset = self
.diff_state
.cursor_line
.saturating_sub(self.diff_state.scroll_offset);
let prev_relative_line = if self.diff_files.is_empty() {
0
} else {
let start = self.calculate_file_scroll_offset(self.diff_state.current_file_idx);
prev_cursor_line.saturating_sub(start)
};
let highlighter = self.theme.syntax_highlighter();
let diff_files = match &self.diff_source {
DiffSource::CommitRange(commit_ids) => Self::get_commit_range_diff_with_ignore(
self.vcs.as_ref(),
&self.vcs_info.root_path,
commit_ids,
highlighter,
self.path_filter.as_deref(),
)?,
DiffSource::StagedUnstagedAndCommits(commit_ids) => {
let ids = commit_ids.clone();
Self::get_working_tree_with_commits_diff_with_ignore(
self.vcs.as_ref(),
&self.vcs_info.root_path,
&ids,
highlighter,
self.path_filter.as_deref(),
)?
}
DiffSource::Staged => Self::get_staged_diff_with_ignore(
self.vcs.as_ref(),
&self.vcs_info.root_path,
highlighter,
self.path_filter.as_deref(),
)?,
DiffSource::Unstaged => Self::get_unstaged_diff_with_ignore(
self.vcs.as_ref(),
&self.vcs_info.root_path,
highlighter,
self.path_filter.as_deref(),
)?,
DiffSource::StagedAndUnstaged | DiffSource::WorkingTree => {
Self::get_working_tree_diff_with_ignore(
self.vcs.as_ref(),
&self.vcs_info.root_path,
highlighter,
self.path_filter.as_deref(),
)?
}
};
for file in &diff_files {
let path = file.display_path().clone();
self.session.add_file(path, file.status);
}
self.diff_files = diff_files;
self.clear_expanded_gaps();
self.sort_files_by_directory(false);
self.expand_all_dirs();
if self.diff_files.is_empty() {
self.diff_state.current_file_idx = 0;
self.diff_state.cursor_line = 0;
self.diff_state.scroll_offset = 0;
self.file_list_state.select(0);
} else {
let target_idx = if let Some(path) = current_path {
self.diff_files
.iter()
.position(|file| file.display_path() == &path)
.unwrap_or_else(|| prev_file_idx.min(self.diff_files.len().saturating_sub(1)))
} else {
prev_file_idx.min(self.diff_files.len().saturating_sub(1))
};
self.jump_to_file(target_idx);
let file_start = self.calculate_file_scroll_offset(target_idx);
let file_height = self.file_render_height(target_idx, &self.diff_files[target_idx]);
let relative_line = prev_relative_line.min(file_height.saturating_sub(1));
self.diff_state.cursor_line = file_start.saturating_add(relative_line);
let viewport = self.diff_state.viewport_height.max(1);
let max_relative = viewport.saturating_sub(1);
let relative_offset = prev_viewport_offset.min(max_relative);
if self.total_lines() == 0 {
self.diff_state.scroll_offset = 0;
} else {
let max_scroll = self.max_scroll_offset();
let desired = self
.diff_state
.cursor_line
.saturating_sub(relative_offset)
.min(max_scroll);
self.diff_state.scroll_offset = desired;
}
self.ensure_cursor_visible();
self.update_current_file_from_cursor();
}
self.rebuild_annotations();
Ok(self.diff_files.len())
}
pub fn current_file(&self) -> Option<&DiffFile> {
self.diff_files.get(self.diff_state.current_file_idx)
}
pub fn current_file_path(&self) -> Option<&PathBuf> {
self.current_file().map(|f| f.display_path())
}
pub fn toggle_reviewed(&mut self) {
let file_idx = self.diff_state.current_file_idx;
self.toggle_reviewed_for_file_idx(file_idx, true);
}
pub fn toggle_reviewed_for_file_idx(&mut self, file_idx: usize, adjust_cursor: bool) {
let Some(path) = self
.diff_files
.get(file_idx)
.map(|file| file.display_path().clone())
else {
return;
};
if let Some(review) = self.session.get_file_mut(&path) {
review.reviewed = !review.reviewed;
self.dirty = true;
self.rebuild_annotations();
if adjust_cursor {
self.diff_state.current_file_idx = file_idx;
let header_line = self.calculate_file_scroll_offset(file_idx);
self.diff_state.cursor_line = header_line;
self.ensure_cursor_visible();
}
}
}
pub fn file_count(&self) -> usize {
self.diff_files.len()
}
pub fn reviewed_count(&self) -> usize {
self.session.reviewed_count()
}
pub fn set_message(&mut self, msg: impl Into<String>) {
self.message = Some(Message {
content: msg.into(),
message_type: MessageType::Info,
});
}
pub fn set_warning(&mut self, msg: impl Into<String>) {
self.message = Some(Message {
content: msg.into(),
message_type: MessageType::Warning,
});
}
pub fn set_error(&mut self, msg: impl Into<String>) {
self.message = Some(Message {
content: msg.into(),
message_type: MessageType::Error,
});
}
pub fn cursor_down(&mut self, lines: usize) {
let max_line = self.total_lines().saturating_sub(1);
self.diff_state.cursor_line = (self.diff_state.cursor_line + lines).min(max_line);
self.ensure_cursor_visible();
self.update_current_file_from_cursor();
}
pub fn cursor_up(&mut self, lines: usize) {
self.diff_state.cursor_line = self.diff_state.cursor_line.saturating_sub(lines);
self.ensure_cursor_visible();
self.update_current_file_from_cursor();
}
pub fn scroll_down(&mut self, lines: usize) {
let total = self.total_lines();
let max_line = total.saturating_sub(1);
let max_scroll = self.max_scroll_offset();
self.diff_state.cursor_line = (self.diff_state.cursor_line + lines).min(max_line);
self.diff_state.scroll_offset = (self.diff_state.scroll_offset + lines).min(max_scroll);
self.ensure_cursor_visible();
self.update_current_file_from_cursor();
}
pub fn scroll_up(&mut self, lines: usize) {
self.diff_state.cursor_line = self.diff_state.cursor_line.saturating_sub(lines);
self.diff_state.scroll_offset = self.diff_state.scroll_offset.saturating_sub(lines);
self.ensure_cursor_visible();
self.update_current_file_from_cursor();
}
pub fn scroll_left(&mut self, cols: usize) {
if self.diff_state.wrap_lines {
return;
}
self.diff_state.scroll_x = self.diff_state.scroll_x.saturating_sub(cols);
}
pub fn scroll_right(&mut self, cols: usize) {
if self.diff_state.wrap_lines {
return;
}
let max_scroll_x = self
.diff_state
.max_content_width
.saturating_sub(self.diff_state.viewport_width);
self.diff_state.scroll_x =
(self.diff_state.scroll_x.saturating_add(cols)).min(max_scroll_x);
}
pub fn toggle_diff_wrap(&mut self) {
let enabled = !self.diff_state.wrap_lines;
self.set_diff_wrap(enabled);
}
pub fn set_diff_wrap(&mut self, enabled: bool) {
self.diff_state.wrap_lines = enabled;
if enabled {
self.diff_state.scroll_x = 0;
}
let status = if self.diff_state.wrap_lines {
"on"
} else {
"off"
};
self.set_message(format!("Diff wrapping: {status}"));
}
fn ensure_cursor_visible(&mut self) {
let visible_lines = if self.diff_state.visible_line_count > 0 {
self.diff_state.visible_line_count
} else {
self.diff_state.viewport_height.max(1)
};
let max_scroll = self.max_scroll_offset();
if self.diff_state.cursor_line < self.diff_state.scroll_offset {
self.diff_state.scroll_offset = self.diff_state.cursor_line;
}
if self.diff_state.cursor_line >= self.diff_state.scroll_offset + visible_lines {
self.diff_state.scroll_offset =
(self.diff_state.cursor_line - visible_lines + 1).min(max_scroll);
}
}
pub fn search_in_diff_from_cursor(&mut self) -> bool {
let pattern = self.search_buffer.clone();
if pattern.trim().is_empty() {
self.set_message("Search pattern is empty");
return false;
}
self.last_search_pattern = Some(pattern.clone());
self.search_in_diff(&pattern, self.diff_state.cursor_line, true, true)
}
pub fn search_next_in_diff(&mut self) -> bool {
let Some(pattern) = self.last_search_pattern.clone() else {
self.set_message("No previous search");
return false;
};
self.search_in_diff(&pattern, self.diff_state.cursor_line, true, false)
}
pub fn search_prev_in_diff(&mut self) -> bool {
let Some(pattern) = self.last_search_pattern.clone() else {
self.set_message("No previous search");
return false;
};
self.search_in_diff(&pattern, self.diff_state.cursor_line, false, false)
}
fn search_in_diff(
&mut self,
pattern: &str,
start_idx: usize,
forward: bool,
include_current: bool,
) -> bool {
let total_lines = self.total_lines();
if total_lines == 0 {
self.set_message("No diff content to search");
return false;
}
if forward {
let mut idx = start_idx.min(total_lines.saturating_sub(1));
if !include_current {
idx = idx.saturating_add(1);
}
for line_idx in idx..total_lines {
if let Some(text) = self.line_text_for_search(line_idx)
&& text.contains(pattern)
{
self.diff_state.cursor_line = line_idx;
self.ensure_cursor_visible();
self.center_cursor();
self.update_current_file_from_cursor();
return true;
}
}
} else {
let mut idx = start_idx.min(total_lines.saturating_sub(1));
if !include_current {
idx = idx.saturating_sub(1);
}
let mut line_idx = idx;
loop {
if let Some(text) = self.line_text_for_search(line_idx)
&& text.contains(pattern)
{
self.diff_state.cursor_line = line_idx;
self.ensure_cursor_visible();
self.center_cursor();
self.update_current_file_from_cursor();
return true;
}
if line_idx == 0 {
break;
}
line_idx = line_idx.saturating_sub(1);
}
}
self.set_message(format!("No matches for \"{pattern}\""));
false
}
fn line_text_for_search(&self, line_idx: usize) -> Option<String> {
match self.line_annotations.get(line_idx)? {
AnnotatedLine::ReviewCommentsHeader => Some("Review comments".to_string()),
AnnotatedLine::ReviewComment { comment_idx } => {
let comment = self.session.review_comments.get(*comment_idx)?;
Some(comment.content.clone())
}
AnnotatedLine::FileHeader { file_idx } => {
let file = self.diff_files.get(*file_idx)?;
Some(format!(
"{} [{}]",
file.display_path().display(),
file.status.as_char()
))
}
AnnotatedLine::FileComment {
file_idx,
comment_idx,
} => {
let path = self.diff_files.get(*file_idx)?.display_path();
let review = self.session.files.get(path)?;
let comment = review.file_comments.get(*comment_idx)?;
Some(comment.content.clone())
}
AnnotatedLine::LineComment {
file_idx,
line,
comment_idx,
..
} => {
let path = self.diff_files.get(*file_idx)?.display_path();
let review = self.session.files.get(path)?;
let comments = review.line_comments.get(line)?;
let comment = comments.get(*comment_idx)?;
Some(comment.content.clone())
}
AnnotatedLine::Expander { gap_id, direction } => {
let arrow = match direction {
ExpandDirection::Down => "↓",
ExpandDirection::Up => "↑",
ExpandDirection::Both => "↕",
};
let gap = self.gap_size(gap_id)?;
let top_len = self.expanded_top.get(gap_id).map_or(0, |v| v.len());
let bot_len = self.expanded_bottom.get(gap_id).map_or(0, |v| v.len());
let remaining = (gap as usize).saturating_sub(top_len + bot_len);
let count = remaining.min(GAP_EXPAND_BATCH);
Some(format!("... {arrow} expand ({count} lines) ..."))
}
AnnotatedLine::HiddenLines { count, .. } => {
Some(format!("... {count} lines hidden ..."))
}
AnnotatedLine::ExpandedContext {
gap_id,
line_idx: context_idx,
} => {
let content = self.get_expanded_line(gap_id, *context_idx)?;
Some(content.content.clone())
}
AnnotatedLine::HunkHeader { file_idx, hunk_idx } => {
let file = self.diff_files.get(*file_idx)?;
let hunk = file.hunks.get(*hunk_idx)?;
Some(hunk.header.clone())
}
AnnotatedLine::DiffLine {
file_idx,
hunk_idx,
line_idx: diff_idx,
..
} => {
let file = self.diff_files.get(*file_idx)?;
let hunk = file.hunks.get(*hunk_idx)?;
let line = hunk.lines.get(*diff_idx)?;
Some(line.content.clone())
}
AnnotatedLine::BinaryOrEmpty { file_idx } => {
let file = self.diff_files.get(*file_idx)?;
if file.is_too_large {
Some("(file too large to display)".to_string())
} else if file.is_binary {
Some("(binary file)".to_string())
} else {
Some("(no changes)".to_string())
}
}
AnnotatedLine::SideBySideLine {
file_idx,
hunk_idx,
del_line_idx,
add_line_idx,
..
} => {
let file = self.diff_files.get(*file_idx)?;
let hunk = file.hunks.get(*hunk_idx)?;
let del_content = del_line_idx
.and_then(|idx| hunk.lines.get(idx))
.map(|l| l.content.as_str())
.unwrap_or("");
let add_content = add_line_idx
.and_then(|idx| hunk.lines.get(idx))
.map(|l| l.content.as_str())
.unwrap_or("");
Some(format!("{} {}", del_content, add_content))
}
AnnotatedLine::Spacing => None,
}
}
fn gap_size(&self, gap_id: &GapId) -> Option<u32> {
let file = self.diff_files.get(gap_id.file_idx)?;
let hunk = file.hunks.get(gap_id.hunk_idx)?;
let prev_hunk = if gap_id.hunk_idx > 0 {
file.hunks.get(gap_id.hunk_idx - 1)
} else {
None
};
Some(calculate_gap(
prev_hunk.map(|h| (&h.new_start, &h.new_count)),
hunk.new_start,
))
}
pub fn center_cursor(&mut self) {
let viewport = self.diff_state.viewport_height.max(1);
let half_viewport = viewport / 2;
let max_scroll = self.max_scroll_offset();
self.diff_state.scroll_offset = self
.diff_state
.cursor_line
.saturating_sub(half_viewport)
.min(max_scroll);
}
pub fn go_to_source_line(&mut self, target_lineno: u32) {
let current_file = self.diff_state.current_file_idx;
let result = find_source_line(&self.line_annotations, current_file, target_lineno);
match result {
FindSourceLineResult::Exact(idx) | FindSourceLineResult::Nearest(idx) => {
self.diff_state.cursor_line = idx;
self.ensure_cursor_visible();
self.center_cursor();
self.update_current_file_from_cursor();
if matches!(result, FindSourceLineResult::Nearest(_)) {
self.set_message(format!(
"Line {target_lineno} not in diff, jumped to nearest"
));
}
}
FindSourceLineResult::NotFound => {
self.set_warning(format!("Line {target_lineno} not found in current file"));
}
}
}
pub fn file_list_down(&mut self, n: usize) {
let visible_items = self.build_visible_items();
let max_idx = visible_items.len().saturating_sub(1);
let new_idx = (self.file_list_state.selected() + n).min(max_idx);
self.file_list_state.select(new_idx);
}
pub fn file_list_up(&mut self, n: usize) {
let new_idx = self.file_list_state.selected().saturating_sub(n);
self.file_list_state.select(new_idx);
}
pub fn jump_to_file(&mut self, idx: usize) {
use std::path::Path;
if idx < self.diff_files.len() {
self.diff_state.current_file_idx = idx;
self.diff_state.cursor_line = self.calculate_file_scroll_offset(idx);
let max_scroll = self.max_scroll_offset();
self.diff_state.scroll_offset = self.diff_state.cursor_line.min(max_scroll);
let file_path = self.diff_files[idx].display_path().clone();
let mut current = file_path.parent();
while let Some(parent) = current {
if parent != Path::new("") {
self.expanded_dirs
.insert(parent.to_string_lossy().to_string());
}
current = parent.parent();
}
if let Some(tree_idx) = self.file_idx_to_tree_idx(idx) {
self.file_list_state.select(tree_idx);
}
}
}
pub fn jump_to_bottom(&mut self) {
let max_line = self.total_lines().saturating_sub(1);
self.diff_state.cursor_line = max_line;
let viewport = self.diff_state.viewport_height.max(1);
self.diff_state.scroll_offset = self.total_lines().saturating_sub(viewport);
self.update_current_file_from_cursor();
}
pub fn next_file(&mut self) {
let visible_items = self.build_visible_items();
let current_file_idx = self.diff_state.current_file_idx;
for item in &visible_items {
if let FileTreeItem::File { file_idx, .. } = item
&& *file_idx > current_file_idx
{
self.jump_to_file(*file_idx);
return;
}
}
}
pub fn prev_file(&mut self) {
let visible_items = self.build_visible_items();
let current_file_idx = self.diff_state.current_file_idx;
for item in visible_items.iter().rev() {
if let FileTreeItem::File { file_idx, .. } = item
&& *file_idx < current_file_idx
{
self.jump_to_file(*file_idx);
return;
}
}
}
fn file_idx_to_tree_idx(&self, target_file_idx: usize) -> Option<usize> {
let visible_items = self.build_visible_items();
for (tree_idx, item) in visible_items.iter().enumerate() {
if let FileTreeItem::File { file_idx, .. } = item
&& *file_idx == target_file_idx
{
return Some(tree_idx);
}
}
None
}
pub fn next_hunk(&mut self) {
let mut cumulative = self.review_comments_render_height();
for file in &self.diff_files {
let path = file.display_path();
cumulative += 1;
if self.session.is_file_reviewed(path) {
continue;
}
if let Some(review) = self.session.files.get(path) {
cumulative += review.file_comments.len();
}
if file.is_binary || file.hunks.is_empty() {
cumulative += 1; } else {
for hunk in &file.hunks {
if cumulative > self.diff_state.cursor_line {
self.diff_state.cursor_line = cumulative;
self.ensure_cursor_visible();
self.update_current_file_from_cursor();
return;
}
cumulative += 1; cumulative += hunk.lines.len(); }
}
cumulative += 1; }
}
pub fn prev_hunk(&mut self) {
let mut hunk_positions: Vec<usize> = Vec::new();
let mut cumulative = self.review_comments_render_height();
for file in &self.diff_files {
let path = file.display_path();
cumulative += 1;
if self.session.is_file_reviewed(path) {
continue;
}
if let Some(review) = self.session.files.get(path) {
cumulative += review.file_comments.len();
}
if file.is_binary || file.hunks.is_empty() {
cumulative += 1;
} else {
for hunk in &file.hunks {
hunk_positions.push(cumulative);
cumulative += 1;
cumulative += hunk.lines.len();
}
}
cumulative += 1;
}
for &pos in hunk_positions.iter().rev() {
if pos < self.diff_state.cursor_line {
self.diff_state.cursor_line = pos;
self.ensure_cursor_visible();
self.update_current_file_from_cursor();
return;
}
}
self.diff_state.cursor_line = 0;
self.ensure_cursor_visible();
self.update_current_file_from_cursor();
}
fn calculate_file_scroll_offset(&self, file_idx: usize) -> usize {
let mut offset = self.review_comments_render_height();
for (i, file) in self.diff_files.iter().enumerate() {
if i == file_idx {
break;
}
offset += self.file_render_height(i, file);
}
offset
}
fn review_comments_render_height(&self) -> usize {
let mut height = 1; for comment in &self.session.review_comments {
height += Self::comment_display_lines(comment);
}
if self.input_mode == InputMode::Comment
&& self.comment_is_review_level
&& self.editing_comment_id.is_none()
{
height += 3;
}
height
}
fn file_render_height(&self, file_idx: usize, file: &DiffFile) -> usize {
let path = file.display_path();
if self.session.is_file_reviewed(path) {
return 1;
}
let header_lines = 1; let spacing_lines = 1; let mut content_lines = 0;
let mut comment_lines = 0;
if let Some(review) = self.session.files.get(path) {
for comment in &review.file_comments {
comment_lines += Self::comment_display_lines(comment);
}
}
if file.is_binary || file.hunks.is_empty() {
content_lines = 1;
} else {
let line_comments = self.session.files.get(path).map(|r| &r.line_comments);
for (hunk_idx, hunk) in file.hunks.iter().enumerate() {
let prev_hunk = if hunk_idx > 0 {
file.hunks.get(hunk_idx - 1)
} else {
None
};
let gap = calculate_gap(
prev_hunk.map(|h| (&h.new_start, &h.new_count)),
hunk.new_start,
);
let gap_id = GapId { file_idx, hunk_idx };
if gap > 0 {
let top_len = self.expanded_top.get(&gap_id).map_or(0, |v| v.len());
let bot_len = self.expanded_bottom.get(&gap_id).map_or(0, |v| v.len());
let remaining = (gap as usize).saturating_sub(top_len + bot_len);
content_lines += top_len + bot_len;
content_lines += gap_annotation_line_count(hunk_idx == 0, remaining);
}
content_lines += 1;
match self.diff_view_mode {
DiffViewMode::Unified => {
for diff_line in &hunk.lines {
content_lines += 1;
if let Some(line_comments) = line_comments {
if let Some(old_ln) = diff_line.old_lineno
&& let Some(comments) = line_comments.get(&old_ln)
{
for comment in comments {
if comment.side == Some(LineSide::Old) {
comment_lines += Self::comment_display_lines(comment);
}
}
}
if let Some(new_ln) = diff_line.new_lineno
&& let Some(comments) = line_comments.get(&new_ln)
{
for comment in comments {
if comment.side != Some(LineSide::Old) {
comment_lines += Self::comment_display_lines(comment);
}
}
}
}
}
}
DiffViewMode::SideBySide => {
use crate::model::LineOrigin;
let lines = &hunk.lines;
let mut i = 0;
while i < lines.len() {
let diff_line = &lines[i];
match diff_line.origin {
LineOrigin::Context => {
content_lines += 1;
if let Some(line_comments) = line_comments
&& let Some(new_ln) = diff_line.new_lineno
&& let Some(comments) = line_comments.get(&new_ln)
{
for comment in comments {
if comment.side != Some(LineSide::Old) {
comment_lines +=
Self::comment_display_lines(comment);
}
}
}
i += 1;
}
LineOrigin::Deletion => {
let del_start = i;
let mut del_end = i + 1;
while del_end < lines.len()
&& lines[del_end].origin == LineOrigin::Deletion
{
del_end += 1;
}
let add_start = del_end;
let mut add_end = add_start;
while add_end < lines.len()
&& lines[add_end].origin == LineOrigin::Addition
{
add_end += 1;
}
let del_count = del_end - del_start;
let add_count = add_end - add_start;
content_lines += del_count.max(add_count);
if let Some(line_comments) = line_comments {
for line in &lines[del_start..del_end] {
if let Some(old_ln) = line.old_lineno
&& let Some(comments) = line_comments.get(&old_ln)
{
for comment in comments {
if comment.side == Some(LineSide::Old) {
comment_lines +=
Self::comment_display_lines(comment);
}
}
}
}
for line in &lines[add_start..add_end] {
if let Some(new_ln) = line.new_lineno
&& let Some(comments) = line_comments.get(&new_ln)
{
for comment in comments {
if comment.side != Some(LineSide::Old) {
comment_lines +=
Self::comment_display_lines(comment);
}
}
}
}
}
i = add_end;
}
LineOrigin::Addition => {
content_lines += 1;
if let Some(line_comments) = line_comments
&& let Some(new_ln) = diff_line.new_lineno
&& let Some(comments) = line_comments.get(&new_ln)
{
for comment in comments {
if comment.side != Some(LineSide::Old) {
comment_lines +=
Self::comment_display_lines(comment);
}
}
}
i += 1;
}
}
}
}
}
}
}
header_lines + comment_lines + content_lines + spacing_lines
}
fn update_current_file_from_cursor(&mut self) {
let mut cumulative = self.review_comments_render_height();
if self.diff_state.cursor_line < cumulative {
if !self.diff_files.is_empty() {
self.diff_state.current_file_idx = 0;
self.file_list_state.select(0);
}
return;
}
for (i, file) in self.diff_files.iter().enumerate() {
let height = self.file_render_height(i, file);
if cumulative + height > self.diff_state.cursor_line {
self.diff_state.current_file_idx = i;
self.file_list_state.select(i);
return;
}
cumulative += height;
}
if !self.diff_files.is_empty() {
self.diff_state.current_file_idx = self.diff_files.len() - 1;
self.file_list_state.select(self.diff_files.len() - 1);
}
}
pub fn total_lines(&self) -> usize {
self.review_comments_render_height()
+ self
.diff_files
.iter()
.enumerate()
.map(|(i, f)| self.file_render_height(i, f))
.sum::<usize>()
}
pub fn max_scroll_offset(&self) -> usize {
let total = self.total_lines();
let viewport = self.diff_state.viewport_height.max(1);
if self.diff_state.wrap_lines {
total.saturating_sub(1)
} else {
total.saturating_sub(viewport)
}
}
fn comment_display_lines(comment: &Comment) -> usize {
let content_lines = comment.content.split('\n').count();
2 + content_lines }
pub fn get_line_at_cursor(&self) -> Option<(u32, LineSide)> {
let target = self.diff_state.cursor_line;
match self.line_annotations.get(target) {
Some(AnnotatedLine::DiffLine {
old_lineno,
new_lineno,
..
})
| Some(AnnotatedLine::SideBySideLine {
old_lineno,
new_lineno,
..
}) => {
new_lineno
.map(|ln| (ln, LineSide::New))
.or_else(|| old_lineno.map(|ln| (ln, LineSide::Old)))
}
_ => None,
}
}
fn find_comment_at_cursor(&self) -> Option<CommentLocation> {
let target = self.diff_state.cursor_line;
match self.line_annotations.get(target) {
Some(AnnotatedLine::ReviewComment { comment_idx }) => Some(CommentLocation::Review {
index: *comment_idx,
}),
Some(AnnotatedLine::FileComment {
file_idx,
comment_idx,
}) => {
let path = self.diff_files.get(*file_idx)?.display_path().clone();
Some(CommentLocation::File {
path,
index: *comment_idx,
})
}
Some(AnnotatedLine::LineComment {
file_idx,
line,
side,
comment_idx,
}) => {
let path = self.diff_files.get(*file_idx)?.display_path().clone();
Some(CommentLocation::Line {
path,
line: *line,
side: *side,
index: *comment_idx,
})
}
_ => None,
}
}
pub fn delete_comment_at_cursor(&mut self) -> bool {
let location = self.find_comment_at_cursor();
match location {
Some(CommentLocation::Review { index }) => {
if index < self.session.review_comments.len() {
self.session.review_comments.remove(index);
self.dirty = true;
self.set_message("Review comment deleted");
self.rebuild_annotations();
return true;
}
}
Some(CommentLocation::File { path, index }) => {
if let Some(review) = self.session.get_file_mut(&path) {
review.file_comments.remove(index);
self.dirty = true;
self.set_message("Comment deleted");
self.rebuild_annotations();
return true;
}
}
Some(CommentLocation::Line {
path,
line,
side,
index,
}) => {
if let Some(review) = self.session.get_file_mut(&path)
&& let Some(comments) = review.line_comments.get_mut(&line)
{
let mut side_idx = 0;
let mut actual_idx = None;
for (i, comment) in comments.iter().enumerate() {
let comment_side = comment.side.unwrap_or(LineSide::New);
if comment_side == side {
if side_idx == index {
actual_idx = Some(i);
break;
}
side_idx += 1;
}
}
if let Some(idx) = actual_idx {
comments.remove(idx);
if comments.is_empty() {
review.line_comments.remove(&line);
}
self.dirty = true;
self.set_message(format!("Comment on line {line} deleted"));
self.rebuild_annotations();
return true;
}
}
}
None => {}
}
false
}
pub fn clear_all_comments(&mut self) {
let (cleared, unreviewed) = self.session.clear_comments();
if cleared == 0 && unreviewed == 0 {
self.set_message("No comments to clear");
return;
}
self.dirty = true;
self.rebuild_annotations();
let msg = match (cleared, unreviewed) {
(0, n) => format!("Unreviewed {n} files"),
(c, 0) => format!("Cleared {c} comments"),
(c, n) => format!("Cleared {c} comments, unreviewed {n} files"),
};
self.set_message(msg);
}
pub fn enter_edit_mode(&mut self) -> bool {
let location = self.find_comment_at_cursor();
match location {
Some(CommentLocation::Review { index }) => {
if let Some(comment) = self.session.review_comments.get(index) {
self.input_mode = InputMode::Comment;
self.comment_buffer = comment.content.clone();
self.comment_cursor = self.comment_buffer.len();
self.comment_type = comment.comment_type.clone();
self.comment_is_review_level = true;
self.comment_is_file_level = false;
self.comment_line = None;
self.editing_comment_id = Some(comment.id.clone());
return true;
}
}
Some(CommentLocation::File { path, index }) => {
if let Some(review) = self.session.files.get(&path)
&& let Some(comment) = review.file_comments.get(index)
{
self.input_mode = InputMode::Comment;
self.comment_buffer = comment.content.clone();
self.comment_cursor = self.comment_buffer.len();
self.comment_type = comment.comment_type.clone();
self.comment_is_review_level = false;
self.comment_is_file_level = true;
self.comment_line = None;
self.editing_comment_id = Some(comment.id.clone());
return true;
}
}
Some(CommentLocation::Line {
path,
line,
side,
index,
}) => {
if let Some(review) = self.session.files.get(&path)
&& let Some(comments) = review.line_comments.get(&line)
{
let mut side_idx = 0;
for comment in comments.iter() {
let comment_side = comment.side.unwrap_or(LineSide::New);
if comment_side == side {
if side_idx == index {
self.input_mode = InputMode::Comment;
self.comment_buffer = comment.content.clone();
self.comment_cursor = self.comment_buffer.len();
self.comment_type = comment.comment_type.clone();
self.comment_is_review_level = false;
self.comment_is_file_level = false;
self.comment_line = Some((line, side));
self.editing_comment_id = Some(comment.id.clone());
return true;
}
side_idx += 1;
}
}
}
}
None => {}
}
false
}
pub fn enter_command_mode(&mut self) {
self.input_mode = InputMode::Command;
self.command_buffer.clear();
}
pub fn exit_command_mode(&mut self) {
self.input_mode = InputMode::Normal;
self.command_buffer.clear();
}
pub fn enter_search_mode(&mut self) {
self.input_mode = InputMode::Search;
self.search_buffer.clear();
}
pub fn exit_search_mode(&mut self) {
self.input_mode = InputMode::Normal;
self.search_buffer.clear();
}
pub fn enter_comment_mode(&mut self, file_level: bool, line: Option<(u32, LineSide)>) {
self.input_mode = InputMode::Comment;
self.comment_buffer.clear();
self.comment_cursor = 0;
self.comment_type = self.default_comment_type();
self.comment_is_review_level = false;
self.comment_is_file_level = file_level;
self.comment_line = line;
}
pub fn enter_review_comment_mode(&mut self) {
self.input_mode = InputMode::Comment;
self.comment_buffer.clear();
self.comment_cursor = 0;
self.comment_type = self.default_comment_type();
self.comment_is_review_level = true;
self.comment_is_file_level = false;
self.comment_line = None;
self.comment_line_range = None;
self.editing_comment_id = None;
}
pub fn exit_comment_mode(&mut self) {
self.input_mode = InputMode::Normal;
self.comment_buffer.clear();
self.comment_cursor = 0;
self.comment_is_review_level = false;
self.editing_comment_id = None;
self.comment_line_range = None;
}
pub fn enter_visual_mode(&mut self, line: u32, side: LineSide) {
self.input_mode = InputMode::VisualSelect;
self.visual_anchor = Some((line, side));
}
pub fn exit_visual_mode(&mut self) {
self.input_mode = InputMode::Normal;
self.visual_anchor = None;
}
pub fn get_visual_selection(&self) -> Option<(LineRange, LineSide)> {
if self.input_mode != InputMode::VisualSelect {
return None;
}
let (anchor_line, anchor_side) = self.visual_anchor?;
let (current_line, current_side) = self.get_line_at_cursor()?;
if anchor_side != current_side {
return None;
}
let range = LineRange::new(anchor_line, current_line);
Some((range, anchor_side))
}
pub fn is_line_in_visual_selection(&self, line: u32, side: LineSide) -> bool {
if let Some((range, sel_side)) = self.get_visual_selection() {
sel_side == side && range.contains(line)
} else {
false
}
}
pub fn enter_comment_from_visual(&mut self) {
if let Some((range, side)) = self.get_visual_selection() {
self.comment_line_range = Some((range, side));
self.comment_line = Some((range.end, side)); self.input_mode = InputMode::Comment;
self.comment_buffer.clear();
self.comment_cursor = 0;
self.comment_type = self.default_comment_type();
self.comment_is_review_level = false;
self.comment_is_file_level = false;
self.visual_anchor = None;
} else {
self.set_warning("Invalid visual selection");
self.exit_visual_mode();
}
}
pub fn save_comment(&mut self) {
if self.comment_buffer.trim().is_empty() {
self.set_message("Comment cannot be empty");
return;
}
let content = self.comment_buffer.trim().to_string();
let mut message = "Error: Could not save comment".to_string();
if let Some(editing_id) = &self.editing_comment_id {
if let Some(comment) = self
.session
.review_comments
.iter_mut()
.find(|c| &c.id == editing_id)
{
comment.content = content.clone();
comment.comment_type = self.comment_type.clone();
message = "Review comment updated".to_string();
} else if let Some(path) = self.current_file_path().cloned()
&& let Some(review) = self.session.get_file_mut(&path)
{
if let Some(comment) = review
.file_comments
.iter_mut()
.find(|c| &c.id == editing_id)
{
comment.content = content.clone();
comment.comment_type = self.comment_type.clone();
message = "Comment updated".to_string();
} else {
let mut found_comment = None;
for comments in review.line_comments.values_mut() {
if let Some(comment) = comments.iter_mut().find(|c| &c.id == editing_id) {
found_comment = Some(comment);
break;
}
}
if let Some(comment) = found_comment {
comment.content = content.clone();
comment.comment_type = self.comment_type.clone();
message = if let Some((line, _)) = self.comment_line {
format!("Comment on line {line} updated")
} else {
"Comment updated".to_string()
};
} else {
message = "Error: Comment to edit not found".to_string();
}
}
}
} else if self.comment_is_review_level {
let comment = Comment::new(content, self.comment_type.clone(), None);
self.session.review_comments.push(comment);
message = "Review comment added".to_string();
} else if let Some(path) = self.current_file_path().cloned()
&& let Some(review) = self.session.get_file_mut(&path)
{
if self.comment_is_file_level {
let comment = Comment::new(content, self.comment_type.clone(), None);
review.add_file_comment(comment);
message = "File comment added".to_string();
} else if let Some((range, side)) = self.comment_line_range {
let comment =
Comment::new_with_range(content, self.comment_type.clone(), Some(side), range);
review.add_line_comment(range.end, comment);
if range.is_single() {
message = format!("Comment added to line {}", range.end);
} else {
message = format!("Comment added to lines {}-{}", range.start, range.end);
}
} else if let Some((line, side)) = self.comment_line {
let comment = Comment::new(content, self.comment_type.clone(), Some(side));
review.add_line_comment(line, comment);
message = format!("Comment added to line {line}");
} else {
let comment = Comment::new(content, self.comment_type.clone(), None);
review.add_file_comment(comment);
message = "File comment added".to_string();
}
}
if !message.starts_with("Error:") {
self.dirty = true;
}
self.set_message(message);
self.rebuild_annotations();
self.exit_comment_mode();
}
pub fn cycle_comment_type(&mut self) {
if self.comment_types.is_empty() {
return;
}
let current_id = self.comment_type.id();
let current_index = self
.comment_types
.iter()
.position(|comment_type| comment_type.id == current_id)
.unwrap_or(0);
let next_index = (current_index + 1) % self.comment_types.len();
self.comment_type = CommentType::from_id(&self.comment_types[next_index].id);
}
pub fn cycle_comment_type_reverse(&mut self) {
if self.comment_types.is_empty() {
return;
}
let current_id = self.comment_type.id();
let current_index = self
.comment_types
.iter()
.position(|comment_type| comment_type.id == current_id)
.unwrap_or(0);
let prev_index = if current_index == 0 {
self.comment_types.len() - 1
} else {
current_index - 1
};
self.comment_type = CommentType::from_id(&self.comment_types[prev_index].id);
}
pub fn toggle_help(&mut self) {
if self.input_mode == InputMode::Help {
self.input_mode = InputMode::Normal;
} else {
self.input_mode = InputMode::Help;
self.help_state.scroll_offset = 0;
}
}
pub fn help_scroll_down(&mut self, lines: usize) {
let max_offset = self
.help_state
.total_lines
.saturating_sub(self.help_state.viewport_height);
self.help_state.scroll_offset = (self.help_state.scroll_offset + lines).min(max_offset);
}
pub fn help_scroll_up(&mut self, lines: usize) {
self.help_state.scroll_offset = self.help_state.scroll_offset.saturating_sub(lines);
}
pub fn help_scroll_to_top(&mut self) {
self.help_state.scroll_offset = 0;
}
pub fn help_scroll_to_bottom(&mut self) {
let max_offset = self
.help_state
.total_lines
.saturating_sub(self.help_state.viewport_height);
self.help_state.scroll_offset = max_offset;
}
pub fn enter_confirm_mode(&mut self, action: ConfirmAction) {
self.input_mode = InputMode::Confirm;
self.pending_confirm = Some(action);
}
pub fn exit_confirm_mode(&mut self) {
self.input_mode = InputMode::Normal;
self.pending_confirm = None;
}
pub fn enter_commit_select_mode(&mut self) -> Result<()> {
if !self.review_commits.is_empty() {
self.saved_inline_selection = self.commit_selection_range;
}
let highlighter = self.theme.syntax_highlighter();
let has_staged_changes = match Self::get_staged_diff_with_ignore(
self.vcs.as_ref(),
&self.vcs_info.root_path,
highlighter,
self.path_filter.as_deref(),
) {
Ok(_) => true,
Err(TuicrError::NoChanges) => false,
Err(TuicrError::UnsupportedOperation(_)) => false,
Err(e) => return Err(e),
};
let has_unstaged_changes = match Self::get_unstaged_diff_with_ignore(
self.vcs.as_ref(),
&self.vcs_info.root_path,
highlighter,
self.path_filter.as_deref(),
) {
Ok(_) => true,
Err(TuicrError::NoChanges) => false,
Err(TuicrError::UnsupportedOperation(_)) => false,
Err(e) => return Err(e),
};
let commits = self.vcs.get_recent_commits(0, VISIBLE_COMMIT_COUNT)?;
if commits.is_empty() && !has_staged_changes && !has_unstaged_changes {
self.set_message("No commits or staged/unstaged changes found");
return Ok(());
}
self.has_more_commit = commits.len() >= VISIBLE_COMMIT_COUNT;
self.commit_list = commits;
if has_staged_changes {
self.commit_list.insert(0, Self::staged_commit_entry());
}
if has_unstaged_changes {
self.commit_list.insert(0, Self::unstaged_commit_entry());
}
self.commit_list_cursor = 0;
self.commit_list_scroll_offset = 0;
self.commit_selection_range = None;
self.visible_commit_count = self.commit_list.len();
self.input_mode = InputMode::CommitSelect;
Ok(())
}
pub fn exit_commit_select_mode(&mut self) -> Result<()> {
self.input_mode = InputMode::Normal;
if !self.review_commits.is_empty() {
self.commit_list = self.review_commits.clone();
self.commit_selection_range = self.saved_inline_selection;
self.commit_list_cursor = 0;
self.commit_list_scroll_offset = 0;
self.visible_commit_count = self.review_commits.len();
self.has_more_commit = false;
self.saved_inline_selection = None;
if self.commit_selection_range.is_some() {
self.reload_inline_selection()?;
}
return Ok(());
}
if matches!(
self.diff_source,
DiffSource::CommitRange(_) | DiffSource::StagedUnstagedAndCommits(_)
) {
let highlighter = self.theme.syntax_highlighter();
match Self::get_working_tree_diff_with_ignore(
self.vcs.as_ref(),
&self.vcs_info.root_path,
highlighter,
self.path_filter.as_deref(),
) {
Ok(diff_files) => {
self.diff_files = diff_files;
self.diff_source = DiffSource::StagedAndUnstaged;
for file in &self.diff_files {
let path = file.display_path().clone();
self.session.add_file(path, file.status);
}
self.sort_files_by_directory(true);
self.expand_all_dirs();
}
Err(_) => {
self.set_message("No staged or unstaged changes");
}
}
}
Ok(())
}
pub fn toggle_diff_view_mode(&mut self) {
self.diff_view_mode = match self.diff_view_mode {
DiffViewMode::Unified => DiffViewMode::SideBySide,
DiffViewMode::SideBySide => DiffViewMode::Unified,
};
let mode_name = match self.diff_view_mode {
DiffViewMode::Unified => "unified",
DiffViewMode::SideBySide => "side-by-side",
};
self.set_message(format!("Diff view mode: {mode_name}"));
self.rebuild_annotations();
}
pub fn toggle_file_list(&mut self) {
self.show_file_list = !self.show_file_list;
if !self.show_file_list && self.focused_panel == FocusedPanel::FileList {
self.focused_panel = FocusedPanel::Diff;
}
let status = if self.show_file_list {
"visible"
} else {
"hidden"
};
self.set_message(format!("File list: {status}"));
}
pub fn has_inline_commit_selector(&self) -> bool {
self.show_commit_selector
&& self.review_commits.len() > 1
&& !matches!(&self.diff_source, DiffSource::WorkingTree)
}
pub fn commit_select_up(&mut self) {
if self.commit_list_cursor > 0 {
self.commit_list_cursor -= 1;
if self.commit_list_cursor < self.commit_list_scroll_offset {
self.commit_list_scroll_offset = self.commit_list_cursor;
}
}
}
pub fn commit_select_down(&mut self) {
let max_cursor = if self.can_show_more_commits() {
self.visible_commit_count
} else {
self.visible_commit_count.saturating_sub(1)
};
if self.commit_list_cursor < max_cursor {
self.commit_list_cursor += 1;
if self.commit_list_viewport_height > 0
&& self.commit_list_cursor
>= self.commit_list_scroll_offset + self.commit_list_viewport_height
{
self.commit_list_scroll_offset =
self.commit_list_cursor - self.commit_list_viewport_height + 1;
}
}
}
pub fn is_on_expand_row(&self) -> bool {
self.can_show_more_commits() && self.commit_list_cursor == self.visible_commit_count
}
pub fn can_show_more_commits(&self) -> bool {
self.visible_commit_count < self.commit_list.len() || self.has_more_commit
}
pub fn expand_commit(&mut self) -> Result<()> {
if self.visible_commit_count < self.commit_list.len() {
self.visible_commit_count =
(self.visible_commit_count + self.commit_page_size).min(self.commit_list.len());
return Ok(());
}
if !self.has_more_commit {
self.set_message("No more commits");
return Ok(());
}
let offset = self.loaded_history_commit_count();
let limit = self.commit_page_size;
let new_commits = self.vcs.get_recent_commits(offset, limit)?;
if new_commits.is_empty() {
self.has_more_commit = false;
self.set_message("No more commits");
return Ok(());
}
if new_commits.len() < limit {
self.has_more_commit = false;
self.set_message("No more commits");
}
self.commit_list.extend(new_commits);
self.visible_commit_count = self.commit_list.len();
Ok(())
}
pub fn toggle_commit_selection(&mut self) {
let cursor = self.commit_list_cursor;
if cursor >= self.commit_list.len() {
return;
}
match self.commit_selection_range {
None => {
self.commit_selection_range = Some((cursor, cursor));
}
Some((start, end)) => {
if cursor >= start && cursor <= end {
if start == end {
self.commit_selection_range = None;
} else if cursor == start {
self.commit_selection_range = Some((start + 1, end));
} else if cursor == end {
self.commit_selection_range = Some((start, end - 1));
} else {
self.commit_selection_range = Some((start, cursor));
}
} else {
let new_start = start.min(cursor);
let new_end = end.max(cursor);
self.commit_selection_range = Some((new_start, new_end));
}
}
}
}
pub fn is_commit_selected(&self, index: usize) -> bool {
match self.commit_selection_range {
Some((start, end)) => index >= start && index <= end,
None => false,
}
}
pub fn cycle_commit_next(&mut self) {
if self.review_commits.is_empty() {
return;
}
let n = self.review_commits.len();
let all_selected = Some((0, n - 1));
if self.commit_selection_range == all_selected {
self.commit_selection_range = Some((n - 1, n - 1));
self.commit_list_cursor = n - 1;
} else if let Some((i, j)) = self.commit_selection_range {
if i == j {
if i == n - 1 {
self.commit_selection_range = all_selected;
} else {
self.commit_selection_range = Some((i + 1, i + 1));
self.commit_list_cursor = i + 1;
}
} else {
self.commit_selection_range = Some((j, j));
self.commit_list_cursor = j;
}
} else {
self.commit_selection_range = all_selected;
}
}
pub fn cycle_commit_prev(&mut self) {
if self.review_commits.is_empty() {
return;
}
let n = self.review_commits.len();
let all_selected = Some((0, n - 1));
if self.commit_selection_range == all_selected {
self.commit_selection_range = Some((0, 0));
self.commit_list_cursor = 0;
} else if let Some((i, j)) = self.commit_selection_range {
if i == j {
if i == 0 {
self.commit_selection_range = all_selected;
} else {
self.commit_selection_range = Some((i - 1, i - 1));
self.commit_list_cursor = i - 1;
}
} else {
self.commit_selection_range = Some((i, i));
self.commit_list_cursor = i;
}
} else {
self.commit_selection_range = all_selected;
}
}
pub fn confirm_commit_selection(&mut self) -> Result<()> {
let Some((start, end)) = self.commit_selection_range else {
self.set_message("Select at least one commit");
return Ok(());
};
let selected_commits: Vec<&CommitInfo> = (start..=end)
.rev()
.filter_map(|i| self.commit_list.get(i))
.collect();
if selected_commits.is_empty() {
self.set_message("Select at least one commit");
return Ok(());
}
let selected_staged = selected_commits.iter().any(|c| Self::is_staged_commit(c));
let selected_unstaged = selected_commits.iter().any(|c| Self::is_unstaged_commit(c));
let selected_ids: Vec<String> = selected_commits
.iter()
.filter(|c| !Self::is_special_commit(c))
.map(|c| c.id.clone())
.collect();
if (selected_staged || selected_unstaged) && !selected_ids.is_empty() {
let all_selected: Vec<CommitInfo> = selected_commits.into_iter().cloned().collect();
return self.load_staged_unstaged_and_commits_selection(selected_ids, all_selected);
}
if selected_staged && selected_unstaged {
return self.load_staged_and_unstaged_selection();
}
if selected_staged {
return self.load_staged_selection();
}
if selected_unstaged {
return self.load_unstaged_selection();
}
let highlighter = self.theme.syntax_highlighter();
let diff_files = Self::get_commit_range_diff_with_ignore(
self.vcs.as_ref(),
&self.vcs_info.root_path,
&selected_ids,
highlighter,
self.path_filter.as_deref(),
)?;
if diff_files.is_empty() {
self.set_message("No changes in selected commits");
return Ok(());
}
let newest_commit_id = selected_ids.last().unwrap().clone();
let loaded_session = load_latest_session_for_context(
&self.vcs_info.root_path,
self.vcs_info.branch_name.as_deref(),
&newest_commit_id,
SessionDiffSource::CommitRange,
Some(selected_ids.as_slice()),
)
.ok()
.and_then(|found| found.map(|(_path, session)| session));
let mut session = loaded_session.unwrap_or_else(|| {
let mut session = ReviewSession::new(
self.vcs_info.root_path.clone(),
newest_commit_id,
self.vcs_info.branch_name.clone(),
SessionDiffSource::CommitRange,
);
session.commit_range = Some(selected_ids.clone());
session
});
if session.commit_range.is_none() {
session.commit_range = Some(selected_ids.clone());
session.updated_at = chrono::Utc::now();
}
self.session = session;
for file in &diff_files {
let path = file.display_path().clone();
self.session.add_file(path, file.status);
}
self.diff_files = diff_files;
self.diff_source = DiffSource::CommitRange(selected_ids);
self.input_mode = InputMode::Normal;
self.diff_state = DiffState::default();
self.file_list_state = FileListState::default();
self.review_commits = selected_commits
.iter()
.rev()
.map(|c| (*c).clone())
.collect();
self.range_diff_files = Some(self.diff_files.clone());
self.commit_list = self.review_commits.clone();
self.commit_list_cursor = 0;
self.commit_selection_range = if self.review_commits.is_empty() {
None
} else {
Some((0, self.review_commits.len() - 1))
};
self.commit_list_scroll_offset = 0;
self.visible_commit_count = self.review_commits.len();
self.has_more_commit = false;
self.show_commit_selector = self.review_commits.len() > 1;
self.commit_diff_cache.clear();
self.saved_inline_selection = None;
self.sort_files_by_directory(true);
self.expand_all_dirs();
self.rebuild_annotations();
Ok(())
}
pub fn reload_inline_selection(&mut self) -> Result<()> {
let Some((start, end)) = self.commit_selection_range else {
self.set_message("Select at least one commit");
return Ok(());
};
if start == 0
&& end == self.review_commits.len() - 1
&& let Some(ref files) = self.range_diff_files
{
self.diff_files = files.clone();
let wrap = self.diff_state.wrap_lines;
self.diff_state = DiffState::default();
self.diff_state.wrap_lines = wrap;
self.file_list_state = FileListState::default();
self.expanded_top.clear();
self.expanded_bottom.clear();
self.insert_commit_message_if_single();
self.sort_files_by_directory(true);
self.expand_all_dirs();
self.rebuild_annotations();
return Ok(());
}
if let Some(files) = self.commit_diff_cache.get(&(start, end)) {
self.diff_files = files.clone();
let wrap = self.diff_state.wrap_lines;
self.diff_state = DiffState::default();
self.diff_state.wrap_lines = wrap;
self.file_list_state = FileListState::default();
self.expanded_top.clear();
self.expanded_bottom.clear();
self.insert_commit_message_if_single();
self.sort_files_by_directory(true);
self.expand_all_dirs();
self.rebuild_annotations();
return Ok(());
}
let has_staged = (start..=end).any(|i| {
self.review_commits
.get(i)
.is_some_and(Self::is_staged_commit)
});
let has_unstaged = (start..=end).any(|i| {
self.review_commits
.get(i)
.is_some_and(Self::is_unstaged_commit)
});
let selected_ids: Vec<String> = (start..=end)
.rev() .filter_map(|i| self.review_commits.get(i))
.filter(|c| !Self::is_special_commit(c))
.map(|c| c.id.clone())
.collect();
let highlighter = self.theme.syntax_highlighter();
let diff_files = if (has_staged || has_unstaged) && !selected_ids.is_empty() {
match Self::get_working_tree_with_commits_diff_with_ignore(
self.vcs.as_ref(),
&self.vcs_info.root_path,
&selected_ids,
highlighter,
self.path_filter.as_deref(),
) {
Ok(files) => files,
Err(TuicrError::NoChanges) => Vec::new(),
Err(e) => return Err(e),
}
} else if has_staged && has_unstaged {
match Self::get_working_tree_diff_with_ignore(
self.vcs.as_ref(),
&self.vcs_info.root_path,
highlighter,
self.path_filter.as_deref(),
) {
Ok(files) => files,
Err(TuicrError::NoChanges) => Vec::new(),
Err(e) => return Err(e),
}
} else if has_staged {
match Self::get_staged_diff_with_ignore(
self.vcs.as_ref(),
&self.vcs_info.root_path,
highlighter,
self.path_filter.as_deref(),
) {
Ok(files) => files,
Err(TuicrError::NoChanges) => Vec::new(),
Err(e) => return Err(e),
}
} else if has_unstaged {
match Self::get_unstaged_diff_with_ignore(
self.vcs.as_ref(),
&self.vcs_info.root_path,
highlighter,
self.path_filter.as_deref(),
) {
Ok(files) => files,
Err(TuicrError::NoChanges) => Vec::new(),
Err(e) => return Err(e),
}
} else {
match Self::get_commit_range_diff_with_ignore(
self.vcs.as_ref(),
&self.vcs_info.root_path,
&selected_ids,
highlighter,
self.path_filter.as_deref(),
) {
Ok(files) => files,
Err(TuicrError::NoChanges) => Vec::new(),
Err(e) => return Err(e),
}
};
self.commit_diff_cache
.insert((start, end), diff_files.clone());
self.diff_files = diff_files;
let wrap = self.diff_state.wrap_lines;
self.diff_state = DiffState::default();
self.diff_state.wrap_lines = wrap;
self.file_list_state = FileListState::default();
self.expanded_top.clear();
self.expanded_bottom.clear();
self.insert_commit_message_if_single();
self.sort_files_by_directory(true);
self.expand_all_dirs();
self.rebuild_annotations();
Ok(())
}
fn load_staged_unstaged_and_commits_selection(
&mut self,
selected_ids: Vec<String>,
selected_commits: Vec<CommitInfo>,
) -> Result<()> {
let highlighter = self.theme.syntax_highlighter();
let diff_files = match Self::get_working_tree_with_commits_diff_with_ignore(
self.vcs.as_ref(),
&self.vcs_info.root_path,
&selected_ids,
highlighter,
self.path_filter.as_deref(),
) {
Ok(diff_files) => diff_files,
Err(TuicrError::NoChanges) => {
self.set_message("No changes in selected commits + staged/unstaged");
return Ok(());
}
Err(e) => return Err(e),
};
self.session =
Self::load_or_create_staged_unstaged_and_commits_session(&self.vcs_info, &selected_ids);
for file in &diff_files {
let path = file.display_path().clone();
self.session.add_file(path, file.status);
}
self.diff_files = diff_files;
self.diff_source = DiffSource::StagedUnstagedAndCommits(selected_ids);
self.input_mode = InputMode::Normal;
self.diff_state = DiffState::default();
self.file_list_state = FileListState::default();
self.review_commits = selected_commits.into_iter().rev().collect();
self.range_diff_files = Some(self.diff_files.clone());
self.commit_list = self.review_commits.clone();
self.commit_list_cursor = 0;
self.commit_selection_range = if self.review_commits.is_empty() {
None
} else {
Some((0, self.review_commits.len() - 1))
};
self.commit_list_scroll_offset = 0;
self.visible_commit_count = self.review_commits.len();
self.has_more_commit = false;
self.show_commit_selector = self.review_commits.len() > 1;
self.commit_diff_cache.clear();
self.saved_inline_selection = None;
self.insert_commit_message_if_single();
self.sort_files_by_directory(true);
self.expand_all_dirs();
self.rebuild_annotations();
Ok(())
}
fn sort_files_by_directory(&mut self, reset_position: bool) {
use std::collections::BTreeMap;
use std::path::Path;
let current_path = if !reset_position {
self.current_file_path().cloned()
} else {
None
};
let mut dir_map: BTreeMap<String, Vec<DiffFile>> = BTreeMap::new();
let mut commit_msg_files: Vec<DiffFile> = Vec::new();
for file in self.diff_files.drain(..) {
if file.is_commit_message {
commit_msg_files.push(file);
continue;
}
let path = file.display_path();
let dir = if let Some(parent) = path.parent() {
if parent == Path::new("") {
".".to_string()
} else {
parent.to_string_lossy().to_string()
}
} else {
".".to_string()
};
dir_map.entry(dir).or_default().push(file);
}
self.diff_files.extend(commit_msg_files);
for (_dir, files) in dir_map {
self.diff_files.extend(files);
}
if let Some(path) = current_path
&& let Some(idx) = self
.diff_files
.iter()
.position(|f| f.display_path() == &path)
{
self.jump_to_file(idx);
return;
}
self.jump_to_file(0);
}
pub fn expand_all_dirs(&mut self) {
use std::path::Path;
self.expanded_dirs.clear();
for file in &self.diff_files {
let path = file.display_path();
let mut current = path.parent();
while let Some(parent) = current {
if parent != Path::new("") {
self.expanded_dirs
.insert(parent.to_string_lossy().to_string());
}
current = parent.parent();
}
}
self.ensure_valid_tree_selection();
}
pub fn collapse_all_dirs(&mut self) {
self.expanded_dirs.clear();
self.ensure_valid_tree_selection();
}
pub fn toggle_directory(&mut self, dir_path: &str) {
if self.expanded_dirs.contains(dir_path) {
self.expanded_dirs.remove(dir_path);
self.ensure_valid_tree_selection();
} else {
self.expanded_dirs.insert(dir_path.to_string());
}
}
fn gap_boundaries(&self, gap_id: &GapId) -> Option<(u32, u32)> {
let file = self.diff_files.get(gap_id.file_idx)?;
let hunk = file.hunks.get(gap_id.hunk_idx)?;
let prev_hunk = if gap_id.hunk_idx > 0 {
file.hunks.get(gap_id.hunk_idx - 1)
} else {
None
};
let (start, end) = match prev_hunk {
None => (1, hunk.new_start.saturating_sub(1)),
Some(prev) => (
prev.new_start + prev.new_count,
hunk.new_start.saturating_sub(1),
),
};
if start > end {
None
} else {
Some((start, end))
}
}
fn get_expanded_line(&self, gap_id: &GapId, idx: usize) -> Option<&DiffLine> {
let top = self.expanded_top.get(gap_id);
let top_len = top.map_or(0, |v| v.len());
if idx < top_len {
top?.get(idx)
} else {
self.expanded_bottom.get(gap_id)?.get(idx - top_len)
}
}
pub fn expand_gap(
&mut self,
gap_id: GapId,
direction: ExpandDirection,
limit: Option<usize>,
) -> Result<()> {
let (gap_start, gap_end) = self
.gap_boundaries(&gap_id)
.ok_or_else(|| TuicrError::CorruptedSession(format!("Invalid gap: {:?}", gap_id)))?;
let file_path = self.diff_files[gap_id.file_idx].display_path().clone();
let file_status = self.diff_files[gap_id.file_idx].status;
let top_len = self.expanded_top.get(&gap_id).map_or(0, |v| v.len()) as u32;
let bot_len = self.expanded_bottom.get(&gap_id).map_or(0, |v| v.len()) as u32;
let inner_start = gap_start + top_len;
let inner_end = gap_end.saturating_sub(bot_len);
if inner_start > inner_end {
return Ok(()); }
match direction {
ExpandDirection::Down => {
let n = limit.unwrap_or(usize::MAX) as u32;
let fetch_end = inner_start.saturating_add(n - 1).min(inner_end);
let new_lines = self.vcs.fetch_context_lines(
&file_path,
file_status,
inner_start,
fetch_end,
)?;
self.expanded_top
.entry(gap_id.clone())
.or_default()
.extend(new_lines);
}
ExpandDirection::Up => {
let n = limit.unwrap_or(usize::MAX) as u32;
let fetch_start = inner_end.saturating_sub(n - 1).max(inner_start);
let new_lines = self.vcs.fetch_context_lines(
&file_path,
file_status,
fetch_start,
inner_end,
)?;
let existing = self.expanded_bottom.remove(&gap_id).unwrap_or_default();
let mut combined = new_lines;
combined.extend(existing);
self.expanded_bottom.insert(gap_id.clone(), combined);
}
ExpandDirection::Both => {
let new_lines = self.vcs.fetch_context_lines(
&file_path,
file_status,
inner_start,
inner_end,
)?;
self.expanded_top
.entry(gap_id.clone())
.or_default()
.extend(new_lines);
}
}
self.rebuild_annotations();
Ok(())
}
pub fn collapse_gap(&mut self, gap_id: GapId) {
self.expanded_top.remove(&gap_id);
self.expanded_bottom.remove(&gap_id);
self.rebuild_annotations();
}
pub fn clear_expanded_gaps(&mut self) {
self.expanded_top.clear();
self.expanded_bottom.clear();
}
pub fn rebuild_annotations(&mut self) {
self.line_annotations.clear();
self.line_annotations
.push(AnnotatedLine::ReviewCommentsHeader);
for (comment_idx, comment) in self.session.review_comments.iter().enumerate() {
let comment_lines = Self::comment_display_lines(comment);
for _ in 0..comment_lines {
self.line_annotations
.push(AnnotatedLine::ReviewComment { comment_idx });
}
}
for (file_idx, file) in self.diff_files.iter().enumerate() {
let path = file.display_path();
self.line_annotations
.push(AnnotatedLine::FileHeader { file_idx });
if self.session.is_file_reviewed(path) {
continue;
}
if let Some(review) = self.session.files.get(path) {
for (comment_idx, comment) in review.file_comments.iter().enumerate() {
let comment_lines = Self::comment_display_lines(comment);
for _ in 0..comment_lines {
self.line_annotations.push(AnnotatedLine::FileComment {
file_idx,
comment_idx,
});
}
}
}
if file.is_binary || file.hunks.is_empty() {
self.line_annotations
.push(AnnotatedLine::BinaryOrEmpty { file_idx });
} else {
let line_comments = self
.session
.files
.get(path)
.map(|r| &r.line_comments)
.cloned()
.unwrap_or_default();
for (hunk_idx, hunk) in file.hunks.iter().enumerate() {
let prev_hunk = if hunk_idx > 0 {
file.hunks.get(hunk_idx - 1)
} else {
None
};
let gap = calculate_gap(
prev_hunk.map(|h| (&h.new_start, &h.new_count)),
hunk.new_start,
);
let gap_id = GapId { file_idx, hunk_idx };
if gap > 0 {
let top_len = self.expanded_top.get(&gap_id).map_or(0, |v| v.len());
let bot_len = self.expanded_bottom.get(&gap_id).map_or(0, |v| v.len());
let remaining = (gap as usize).saturating_sub(top_len + bot_len);
let is_top_of_file = hunk_idx == 0;
let mut ctx_idx = 0;
for _ in 0..top_len {
self.line_annotations.push(AnnotatedLine::ExpandedContext {
gap_id: gap_id.clone(),
line_idx: ctx_idx,
});
ctx_idx += 1;
}
if remaining > 0 {
if is_top_of_file {
if remaining > GAP_EXPAND_BATCH {
self.line_annotations.push(AnnotatedLine::HiddenLines {
gap_id: gap_id.clone(),
count: remaining,
});
}
self.line_annotations.push(AnnotatedLine::Expander {
gap_id: gap_id.clone(),
direction: ExpandDirection::Up,
});
} else if remaining >= GAP_EXPAND_BATCH {
self.line_annotations.push(AnnotatedLine::Expander {
gap_id: gap_id.clone(),
direction: ExpandDirection::Down,
});
self.line_annotations.push(AnnotatedLine::HiddenLines {
gap_id: gap_id.clone(),
count: remaining,
});
self.line_annotations.push(AnnotatedLine::Expander {
gap_id: gap_id.clone(),
direction: ExpandDirection::Up,
});
} else {
self.line_annotations.push(AnnotatedLine::Expander {
gap_id: gap_id.clone(),
direction: ExpandDirection::Both,
});
}
}
for _ in 0..bot_len {
self.line_annotations.push(AnnotatedLine::ExpandedContext {
gap_id: gap_id.clone(),
line_idx: ctx_idx,
});
ctx_idx += 1;
}
}
self.line_annotations
.push(AnnotatedLine::HunkHeader { file_idx, hunk_idx });
match self.diff_view_mode {
DiffViewMode::Unified => {
Self::build_unified_diff_annotations(
&mut self.line_annotations,
file_idx,
hunk_idx,
&hunk.lines,
&line_comments,
);
}
DiffViewMode::SideBySide => {
Self::build_side_by_side_annotations(
&mut self.line_annotations,
file_idx,
hunk_idx,
&hunk.lines,
&line_comments,
);
}
}
}
}
self.line_annotations.push(AnnotatedLine::Spacing);
}
}
fn push_comments(
annotations: &mut Vec<AnnotatedLine>,
file_idx: usize,
line_no: Option<u32>,
line_comments: &std::collections::HashMap<u32, Vec<crate::model::Comment>>,
side: LineSide,
) {
let Some(ln) = line_no else {
return;
};
let Some(comments) = line_comments.get(&ln) else {
return;
};
for (idx, comment) in comments.iter().enumerate() {
let matches_side =
comment.side == Some(side) || (side == LineSide::New && comment.side.is_none());
if !matches_side {
continue;
}
let comment_lines = Self::comment_display_lines(comment);
for _ in 0..comment_lines {
annotations.push(AnnotatedLine::LineComment {
file_idx,
line: ln,
comment_idx: idx,
side,
});
}
}
}
fn build_unified_diff_annotations(
annotations: &mut Vec<AnnotatedLine>,
file_idx: usize,
hunk_idx: usize,
lines: &[crate::model::DiffLine],
line_comments: &std::collections::HashMap<u32, Vec<crate::model::Comment>>,
) {
for (line_idx, diff_line) in lines.iter().enumerate() {
annotations.push(AnnotatedLine::DiffLine {
file_idx,
hunk_idx,
line_idx,
old_lineno: diff_line.old_lineno,
new_lineno: diff_line.new_lineno,
});
if let Some(old_ln) = diff_line.old_lineno {
Self::push_comments(
annotations,
file_idx,
Some(old_ln),
line_comments,
LineSide::Old,
);
}
if let Some(new_ln) = diff_line.new_lineno {
Self::push_comments(
annotations,
file_idx,
Some(new_ln),
line_comments,
LineSide::New,
);
}
}
}
fn build_side_by_side_annotations(
annotations: &mut Vec<AnnotatedLine>,
file_idx: usize,
hunk_idx: usize,
lines: &[crate::model::DiffLine],
line_comments: &std::collections::HashMap<u32, Vec<crate::model::Comment>>,
) {
let mut i = 0;
while i < lines.len() {
let diff_line = &lines[i];
match diff_line.origin {
LineOrigin::Context => {
annotations.push(AnnotatedLine::SideBySideLine {
file_idx,
hunk_idx,
del_line_idx: Some(i),
add_line_idx: Some(i),
old_lineno: diff_line.old_lineno,
new_lineno: diff_line.new_lineno,
});
Self::push_comments(
annotations,
file_idx,
diff_line.new_lineno,
line_comments,
LineSide::New,
);
i += 1
}
LineOrigin::Deletion => {
let del_start = i;
let mut del_end = i + 1;
while del_end < lines.len() && lines[del_end].origin == LineOrigin::Deletion {
del_end += 1;
}
let add_start = del_end;
let mut add_end = add_start;
while add_end < lines.len() && lines[add_end].origin == LineOrigin::Addition {
add_end += 1;
}
let del_count = del_end - del_start;
let add_count = add_end - add_start;
let max_lines = del_count.max(add_count);
for offset in 0..max_lines {
let del_idx = if offset < del_count {
Some(del_start + offset)
} else {
None
};
let add_idx = if offset < add_count {
Some(add_start + offset)
} else {
None
};
let old_lineno = del_idx.and_then(|idx| lines[idx].old_lineno);
let new_lineno = add_idx.and_then(|idx| lines[idx].new_lineno);
annotations.push(AnnotatedLine::SideBySideLine {
file_idx,
hunk_idx,
del_line_idx: del_idx,
add_line_idx: add_idx,
old_lineno,
new_lineno,
});
Self::push_comments(
annotations,
file_idx,
old_lineno,
line_comments,
LineSide::Old,
);
Self::push_comments(
annotations,
file_idx,
new_lineno,
line_comments,
LineSide::New,
);
}
i = add_end;
}
LineOrigin::Addition => {
annotations.push(AnnotatedLine::SideBySideLine {
file_idx,
hunk_idx,
del_line_idx: None,
add_line_idx: Some(i),
old_lineno: None,
new_lineno: diff_line.new_lineno,
});
Self::push_comments(
annotations,
file_idx,
diff_line.new_lineno,
line_comments,
LineSide::New,
);
i += 1;
}
}
}
}
pub fn get_gap_at_cursor(&self) -> Option<GapCursorHit> {
let target = self.diff_state.cursor_line;
match self.line_annotations.get(target) {
Some(AnnotatedLine::Expander { gap_id, direction }) => {
Some(GapCursorHit::Expander(gap_id.clone(), *direction))
}
Some(AnnotatedLine::HiddenLines { gap_id, .. }) => {
Some(GapCursorHit::HiddenLines(gap_id.clone()))
}
Some(AnnotatedLine::ExpandedContext { gap_id, .. }) => {
Some(GapCursorHit::ExpandedContent(gap_id.clone()))
}
_ => None,
}
}
fn ensure_valid_tree_selection(&mut self) {
use std::path::Path;
let visible_items = self.build_visible_items();
if visible_items.is_empty() {
self.file_list_state.select(0);
return;
}
let current_file_idx = self.diff_state.current_file_idx;
let file_visible = visible_items.iter().any(|item| {
matches!(item, FileTreeItem::File { file_idx, .. } if *file_idx == current_file_idx)
});
if file_visible {
if let Some(tree_idx) = self.file_idx_to_tree_idx(current_file_idx) {
self.file_list_state.select(tree_idx);
}
} else {
if let Some(file) = self.diff_files.get(current_file_idx) {
let file_path = file.display_path();
let mut current = file_path.parent();
while let Some(parent) = current {
if parent != Path::new("") {
let parent_str = parent.to_string_lossy().to_string();
for (tree_idx, item) in visible_items.iter().enumerate() {
if let FileTreeItem::Directory { path, .. } = item
&& *path == parent_str
{
self.file_list_state.select(tree_idx);
return;
}
}
}
current = parent.parent();
}
}
self.file_list_state.select(0);
}
}
pub fn build_visible_items(&self) -> Vec<FileTreeItem> {
use std::path::Path;
let mut items = Vec::new();
let mut seen_dirs: HashSet<String> = HashSet::new();
for (file_idx, file) in self.diff_files.iter().enumerate() {
let path = file.display_path();
let mut ancestors: Vec<String> = Vec::new();
let mut current = path.parent();
while let Some(parent) = current {
if parent != Path::new("") {
ancestors.push(parent.to_string_lossy().to_string());
}
current = parent.parent();
}
ancestors.reverse();
let mut visible = true;
for (depth, dir) in ancestors.iter().enumerate() {
if !seen_dirs.contains(dir) && visible {
let expanded = self.expanded_dirs.contains(dir);
items.push(FileTreeItem::Directory {
path: dir.clone(),
depth,
expanded,
});
seen_dirs.insert(dir.clone());
}
if !self.expanded_dirs.contains(dir) {
visible = false;
}
}
if visible {
items.push(FileTreeItem::File {
file_idx,
depth: ancestors.len(),
});
}
}
items
}
pub fn get_selected_tree_item(&self) -> Option<FileTreeItem> {
let visible_items = self.build_visible_items();
let selected_idx = self.file_list_state.selected();
visible_items.get(selected_idx).cloned()
}
}
#[cfg(test)]
mod tree_tests {
use super::*;
use crate::model::{DiffFile, FileStatus};
fn make_file(path: &str) -> DiffFile {
DiffFile {
old_path: None,
new_path: Some(PathBuf::from(path)),
status: FileStatus::Modified,
hunks: vec![],
is_binary: false,
is_too_large: false,
is_commit_message: false,
}
}
struct TreeTestHarness {
diff_files: Vec<DiffFile>,
expanded_dirs: HashSet<String>,
}
impl TreeTestHarness {
fn new(paths: &[&str]) -> Self {
Self {
diff_files: paths.iter().map(|p| make_file(p)).collect(),
expanded_dirs: HashSet::new(),
}
}
fn expand_all(&mut self) {
use std::path::Path;
for file in &self.diff_files {
let path = file.display_path();
let mut current = path.parent();
while let Some(parent) = current {
if parent != Path::new("") {
self.expanded_dirs
.insert(parent.to_string_lossy().to_string());
}
current = parent.parent();
}
}
}
fn collapse_all(&mut self) {
self.expanded_dirs.clear();
}
fn toggle(&mut self, dir: &str) {
if self.expanded_dirs.contains(dir) {
self.expanded_dirs.remove(dir);
} else {
self.expanded_dirs.insert(dir.to_string());
}
}
fn build_visible_items(&self) -> Vec<FileTreeItem> {
use std::path::Path;
let mut items = Vec::new();
let mut seen_dirs: HashSet<String> = HashSet::new();
for (file_idx, file) in self.diff_files.iter().enumerate() {
let path = file.display_path();
let mut ancestors: Vec<String> = Vec::new();
let mut current = path.parent();
while let Some(parent) = current {
if parent != Path::new("") {
ancestors.push(parent.to_string_lossy().to_string());
}
current = parent.parent();
}
ancestors.reverse();
let mut visible = true;
for (depth, dir) in ancestors.iter().enumerate() {
if !seen_dirs.contains(dir) && visible {
let expanded = self.expanded_dirs.contains(dir);
items.push(FileTreeItem::Directory {
path: dir.clone(),
depth,
expanded,
});
seen_dirs.insert(dir.clone());
}
if !self.expanded_dirs.contains(dir) {
visible = false;
}
}
if visible {
items.push(FileTreeItem::File {
file_idx,
depth: ancestors.len(),
});
}
}
items
}
fn visible_file_count(&self) -> usize {
self.build_visible_items()
.iter()
.filter(|i| matches!(i, FileTreeItem::File { .. }))
.count()
}
fn visible_dir_count(&self) -> usize {
self.build_visible_items()
.iter()
.filter(|i| matches!(i, FileTreeItem::Directory { .. }))
.count()
}
}
#[test]
fn test_expand_all_shows_all_files() {
let mut h = TreeTestHarness::new(&["src/ui/app.rs", "src/ui/help.rs", "src/main.rs"]);
h.expand_all();
assert_eq!(h.visible_file_count(), 3);
}
#[test]
fn test_collapse_all_hides_all_files() {
let mut h = TreeTestHarness::new(&["src/ui/app.rs", "src/main.rs"]);
h.expand_all();
h.collapse_all();
assert_eq!(h.visible_file_count(), 0);
assert_eq!(h.visible_dir_count(), 1); }
#[test]
fn test_collapse_parent_hides_nested_dirs() {
let mut h = TreeTestHarness::new(&["src/ui/components/button.rs"]);
h.expand_all();
assert_eq!(h.visible_dir_count(), 3);
h.toggle("src");
let items = h.build_visible_items();
assert_eq!(items.len(), 1); assert!(matches!(
&items[0],
FileTreeItem::Directory {
expanded: false,
..
}
));
}
#[test]
fn test_root_files_always_visible() {
let mut h = TreeTestHarness::new(&["README.md", "Cargo.toml"]);
h.collapse_all();
assert_eq!(h.visible_file_count(), 2);
}
#[test]
fn test_tree_depth_correct() {
let mut h = TreeTestHarness::new(&["a/b/c/file.rs"]);
h.expand_all();
let items = h.build_visible_items();
assert!(matches!(&items[0], FileTreeItem::Directory { depth: 0, path, .. } if path == "a"));
assert!(
matches!(&items[1], FileTreeItem::Directory { depth: 1, path, .. } if path == "a/b")
);
assert!(
matches!(&items[2], FileTreeItem::Directory { depth: 2, path, .. } if path == "a/b/c")
);
assert!(matches!(&items[3], FileTreeItem::File { depth: 3, .. }));
}
#[test]
fn test_toggle_expands_collapsed_dir() {
let mut h = TreeTestHarness::new(&["src/main.rs"]);
h.collapse_all();
assert_eq!(h.visible_file_count(), 0);
h.toggle("src");
assert_eq!(h.visible_file_count(), 1);
}
#[test]
fn test_sibling_dirs_independent() {
let mut h = TreeTestHarness::new(&["src/app.rs", "tests/test.rs"]);
h.expand_all();
h.toggle("src");
assert_eq!(h.visible_file_count(), 1); }
}
#[cfg(test)]
mod commit_selection_tests {
use super::*;
use crate::model::FileStatus;
use crate::vcs::traits::VcsType;
struct DummyVcs {
info: VcsInfo,
}
impl VcsBackend for DummyVcs {
fn info(&self) -> &VcsInfo {
&self.info
}
fn get_working_tree_diff(&self, _highlighter: &SyntaxHighlighter) -> Result<Vec<DiffFile>> {
Err(TuicrError::NoChanges)
}
fn fetch_context_lines(
&self,
_file_path: &Path,
_file_status: FileStatus,
_start_line: u32,
_end_line: u32,
) -> Result<Vec<DiffLine>> {
Ok(Vec::new())
}
}
fn build_app(commit_list: Vec<CommitInfo>) -> App {
let vcs_info = VcsInfo {
root_path: PathBuf::from("/tmp"),
head_commit: "head".to_string(),
branch_name: Some("main".to_string()),
vcs_type: VcsType::Git,
};
let session = ReviewSession::new(
vcs_info.root_path.clone(),
vcs_info.head_commit.clone(),
vcs_info.branch_name.clone(),
SessionDiffSource::WorkingTree,
);
App::build(
Box::new(DummyVcs {
info: vcs_info.clone(),
}),
vcs_info,
Theme::dark(),
None,
false,
Vec::new(),
session,
DiffSource::WorkingTree,
InputMode::CommitSelect,
commit_list,
None,
)
.expect("failed to build test app")
}
fn normal_commit(id: &str) -> CommitInfo {
CommitInfo {
id: id.to_string(),
short_id: id.to_string(),
branch_name: None,
summary: "Test commit".to_string(),
body: None,
author: "Test".to_string(),
time: Utc::now(),
}
}
#[test]
fn special_commit_count_counts_leading_special_entries() {
let app = build_app(vec![
App::staged_commit_entry(),
App::unstaged_commit_entry(),
normal_commit("abc123"),
]);
assert_eq!(app.special_commit_count(), 2);
}
#[test]
fn special_commit_count_ignores_non_leading_special_entries() {
let app = build_app(vec![normal_commit("abc123"), App::staged_commit_entry()]);
assert_eq!(app.special_commit_count(), 0);
}
}
#[cfg(test)]
mod scroll_tests {
use super::*;
fn calc_max_scroll(total_lines: usize, viewport_height: usize, wrap_lines: bool) -> usize {
let viewport = viewport_height.max(1);
if wrap_lines {
total_lines.saturating_sub(1)
} else {
total_lines.saturating_sub(viewport)
}
}
#[test]
fn should_calculate_max_scroll_without_wrapping() {
let total = 103;
let viewport = 20;
let max_scroll = calc_max_scroll(total, viewport, false);
assert_eq!(max_scroll, 83); }
#[test]
fn should_calculate_max_scroll_with_wrapping() {
let total = 103;
let viewport = 20;
let max_scroll = calc_max_scroll(total, viewport, true);
assert_eq!(max_scroll, 102); }
#[test]
fn should_allow_scrolling_further_with_wrapping() {
let total = 103;
let viewport = 20;
let max_no_wrap = calc_max_scroll(total, viewport, false);
let max_with_wrap = calc_max_scroll(total, viewport, true);
assert!(
max_with_wrap > max_no_wrap,
"With wrapping, max_scroll ({}) should be greater than without ({})",
max_with_wrap,
max_no_wrap
);
assert_eq!(max_with_wrap - max_no_wrap, viewport - 1);
}
#[test]
fn should_handle_small_content_without_wrapping() {
let total = 13;
let viewport = 50;
let max_scroll = calc_max_scroll(total, viewport, false);
assert_eq!(max_scroll, 0);
}
#[test]
fn should_handle_small_content_with_wrapping() {
let total = 13;
let viewport = 50;
let max_scroll = calc_max_scroll(total, viewport, true);
assert_eq!(max_scroll, 12); }
#[test]
fn should_handle_empty_content() {
let total = 0;
let viewport = 20;
let max_scroll_no_wrap = calc_max_scroll(total, viewport, false);
let max_scroll_wrap = calc_max_scroll(total, viewport, true);
assert_eq!(max_scroll_no_wrap, 0);
assert_eq!(max_scroll_wrap, 0);
}
#[test]
fn should_handle_zero_viewport() {
let total = 100;
let viewport = 0;
let max_scroll_no_wrap = calc_max_scroll(total, viewport, false);
let max_scroll_wrap = calc_max_scroll(total, viewport, true);
assert_eq!(max_scroll_no_wrap, 99); assert_eq!(max_scroll_wrap, 99); }
#[test]
fn should_match_max_scroll_offset_implementation() {
let diff_state_no_wrap = DiffState {
viewport_height: 20,
wrap_lines: false,
..Default::default()
};
let diff_state_wrap = DiffState {
viewport_height: 20,
wrap_lines: true,
..Default::default()
};
assert!(!diff_state_no_wrap.wrap_lines);
assert!(diff_state_wrap.wrap_lines);
assert_eq!(diff_state_no_wrap.viewport_height, 20);
assert_eq!(diff_state_wrap.viewport_height, 20);
}
}
#[cfg(test)]
mod find_source_line_tests {
use super::*;
fn make_diff_line(file_idx: usize, new_lineno: Option<u32>) -> AnnotatedLine {
AnnotatedLine::DiffLine {
file_idx,
hunk_idx: 0,
line_idx: 0,
old_lineno: None,
new_lineno,
}
}
fn make_sbs_line(file_idx: usize, new_lineno: Option<u32>) -> AnnotatedLine {
AnnotatedLine::SideBySideLine {
file_idx,
hunk_idx: 0,
del_line_idx: None,
add_line_idx: None,
old_lineno: None,
new_lineno,
}
}
#[test]
fn should_find_exact_match() {
let annotations = vec![
AnnotatedLine::FileHeader { file_idx: 0 },
make_diff_line(0, Some(10)),
make_diff_line(0, Some(11)),
make_diff_line(0, Some(12)),
];
let result = find_source_line(&annotations, 0, 11);
assert_eq!(result, FindSourceLineResult::Exact(2));
}
#[test]
fn should_find_nearest_when_no_exact_match() {
let annotations = vec![
make_diff_line(0, Some(10)),
make_diff_line(0, Some(15)),
make_diff_line(0, Some(20)),
];
let result = find_source_line(&annotations, 0, 12);
assert_eq!(result, FindSourceLineResult::Nearest(0));
}
#[test]
fn should_find_nearest_above_target() {
let annotations = vec![
make_diff_line(0, Some(10)),
make_diff_line(0, Some(15)),
make_diff_line(0, Some(20)),
];
let result = find_source_line(&annotations, 0, 18);
assert_eq!(result, FindSourceLineResult::Nearest(2));
}
#[test]
fn should_return_not_found_for_empty_annotations() {
let annotations: Vec<AnnotatedLine> = vec![];
let result = find_source_line(&annotations, 0, 42);
assert_eq!(result, FindSourceLineResult::NotFound);
}
#[test]
fn should_return_not_found_when_no_lines_in_current_file() {
let annotations = vec![make_diff_line(1, Some(10)), make_diff_line(1, Some(20))];
let result = find_source_line(&annotations, 0, 10);
assert_eq!(result, FindSourceLineResult::NotFound);
}
#[test]
fn should_skip_lines_from_other_files() {
let annotations = vec![
make_diff_line(0, Some(100)), make_diff_line(1, Some(42)), make_diff_line(0, Some(50)), ];
let result = find_source_line(&annotations, 0, 42);
assert_eq!(result, FindSourceLineResult::Nearest(2));
}
#[test]
fn should_skip_non_diff_line_annotations() {
let annotations = vec![
AnnotatedLine::FileHeader { file_idx: 0 },
AnnotatedLine::HunkHeader {
file_idx: 0,
hunk_idx: 0,
},
AnnotatedLine::Spacing,
make_diff_line(0, Some(42)),
];
let result = find_source_line(&annotations, 0, 42);
assert_eq!(result, FindSourceLineResult::Exact(3));
}
#[test]
fn should_skip_diff_lines_with_no_new_lineno() {
let annotations = vec![make_diff_line(0, None), make_diff_line(0, Some(20))];
let result = find_source_line(&annotations, 0, 5);
assert_eq!(result, FindSourceLineResult::Nearest(1));
}
#[test]
fn should_work_with_side_by_side_lines() {
let annotations = vec![
make_sbs_line(0, Some(10)),
make_sbs_line(0, Some(20)),
make_sbs_line(0, Some(30)),
];
let result = find_source_line(&annotations, 0, 20);
assert_eq!(result, FindSourceLineResult::Exact(1));
}
#[test]
fn should_handle_mixed_diff_and_sbs_lines() {
let annotations = vec![
make_diff_line(0, Some(10)),
make_sbs_line(0, Some(20)),
make_diff_line(0, Some(30)),
];
let result = find_source_line(&annotations, 0, 25);
assert_eq!(result, FindSourceLineResult::Nearest(1));
}
#[test]
fn should_return_not_found_when_only_non_line_annotations() {
let annotations = vec![
AnnotatedLine::FileHeader { file_idx: 0 },
AnnotatedLine::Spacing,
AnnotatedLine::HunkHeader {
file_idx: 0,
hunk_idx: 0,
},
];
let result = find_source_line(&annotations, 0, 42);
assert_eq!(result, FindSourceLineResult::NotFound);
}
#[test]
fn should_prefer_exact_match_over_earlier_nearest() {
let annotations = vec![
make_diff_line(0, Some(41)), make_diff_line(0, Some(42)), make_diff_line(0, Some(43)), ];
let result = find_source_line(&annotations, 0, 42);
assert_eq!(result, FindSourceLineResult::Exact(1));
}
#[test]
fn should_find_nearest_for_target_zero() {
let annotations = vec![make_diff_line(0, Some(1)), make_diff_line(0, Some(5))];
let result = find_source_line(&annotations, 0, 0);
assert_eq!(result, FindSourceLineResult::Nearest(0));
}
#[test]
fn should_tie_break_nearest_by_iteration_order() {
let annotations = vec![
make_diff_line(0, Some(30)),
make_diff_line(0, Some(50)),
make_diff_line(0, Some(10)),
];
let result = find_source_line(&annotations, 0, 20);
assert_eq!(result, FindSourceLineResult::Nearest(0));
}
}
#[cfg(test)]
mod expand_gap_tests {
use super::*;
use crate::model::{DiffHunk, DiffLine, FileStatus, LineOrigin};
use crate::vcs::traits::VcsType;
struct MockVcs {
info: VcsInfo,
total_lines: u32,
}
impl VcsBackend for MockVcs {
fn info(&self) -> &VcsInfo {
&self.info
}
fn get_working_tree_diff(&self, _highlighter: &SyntaxHighlighter) -> Result<Vec<DiffFile>> {
Err(TuicrError::NoChanges)
}
fn fetch_context_lines(
&self,
_file_path: &Path,
_file_status: FileStatus,
start_line: u32,
end_line: u32,
) -> Result<Vec<DiffLine>> {
let mut result = Vec::new();
for line_num in start_line..=end_line.min(self.total_lines) {
result.push(DiffLine {
origin: LineOrigin::Context,
content: format!("line {line_num}"),
old_lineno: Some(line_num),
new_lineno: Some(line_num),
highlighted_spans: None,
});
}
Ok(result)
}
}
fn make_hunk(new_start: u32, new_count: u32) -> DiffHunk {
let mut lines = Vec::new();
for i in 0..new_count {
lines.push(DiffLine {
origin: LineOrigin::Context,
content: format!("hunk line {}", new_start + i),
old_lineno: Some(new_start + i),
new_lineno: Some(new_start + i),
highlighted_spans: None,
});
}
DiffHunk {
header: format!("@@ -{new_start},{new_count} +{new_start},{new_count} @@"),
lines,
old_start: new_start,
old_count: new_count,
new_start,
new_count,
}
}
fn build_app_with_files(files: Vec<DiffFile>, total_lines: u32) -> App {
let vcs_info = VcsInfo {
root_path: PathBuf::from("/tmp"),
head_commit: "abc123".to_string(),
branch_name: Some("main".to_string()),
vcs_type: VcsType::Git,
};
let session = ReviewSession::new(
vcs_info.root_path.clone(),
vcs_info.head_commit.clone(),
vcs_info.branch_name.clone(),
SessionDiffSource::WorkingTree,
);
App::build(
Box::new(MockVcs {
info: vcs_info.clone(),
total_lines,
}),
vcs_info,
Theme::dark(),
None,
false,
files,
session,
DiffSource::WorkingTree,
InputMode::Normal,
Vec::new(),
None,
)
.expect("failed to build test app")
}
fn make_file_with_hunks(path: &str, hunks: Vec<DiffHunk>) -> DiffFile {
DiffFile {
old_path: None,
new_path: Some(PathBuf::from(path)),
status: FileStatus::Modified,
hunks,
is_binary: false,
is_too_large: false,
is_commit_message: false,
}
}
#[test]
fn should_expand_up_from_first_hunk() {
let file = make_file_with_hunks("test.rs", vec![make_hunk(51, 5)]);
let mut app = build_app_with_files(vec![file], 100);
let gap_id = GapId {
file_idx: 0,
hunk_idx: 0,
};
app.expand_gap(gap_id.clone(), ExpandDirection::Up, Some(20))
.unwrap();
let content = app.expanded_bottom.get(&gap_id).unwrap();
assert_eq!(content.len(), 20);
assert_eq!(content[0].new_lineno, Some(31));
assert_eq!(content[19].new_lineno, Some(50));
}
#[test]
fn should_expand_all_lines_with_both_direction() {
let file = make_file_with_hunks("test.rs", vec![make_hunk(51, 5)]);
let mut app = build_app_with_files(vec![file], 100);
let gap_id = GapId {
file_idx: 0,
hunk_idx: 0,
};
app.expand_gap(gap_id.clone(), ExpandDirection::Both, None)
.unwrap();
let content = app.expanded_top.get(&gap_id).unwrap();
assert_eq!(content.len(), 50);
assert_eq!(content[0].new_lineno, Some(1));
assert_eq!(content[49].new_lineno, Some(50));
}
#[test]
fn should_expand_down_from_upper_hunk() {
let file = make_file_with_hunks("test.rs", vec![make_hunk(1, 5), make_hunk(30, 5)]);
let mut app = build_app_with_files(vec![file], 100);
let gap_id = GapId {
file_idx: 0,
hunk_idx: 1,
};
app.expand_gap(gap_id.clone(), ExpandDirection::Down, Some(10))
.unwrap();
let content = app.expanded_top.get(&gap_id).unwrap();
assert_eq!(content.len(), 10);
assert_eq!(content[0].new_lineno, Some(6));
assert_eq!(content[9].new_lineno, Some(15));
}
#[test]
fn should_expand_up_from_lower_hunk() {
let file = make_file_with_hunks("test.rs", vec![make_hunk(1, 5), make_hunk(30, 5)]);
let mut app = build_app_with_files(vec![file], 100);
let gap_id = GapId {
file_idx: 0,
hunk_idx: 1,
};
app.expand_gap(gap_id.clone(), ExpandDirection::Up, Some(10))
.unwrap();
let content = app.expanded_bottom.get(&gap_id).unwrap();
assert_eq!(content.len(), 10);
assert_eq!(content[0].new_lineno, Some(20));
assert_eq!(content[9].new_lineno, Some(29));
}
#[test]
fn should_append_on_subsequent_down_expand() {
let file = make_file_with_hunks("test.rs", vec![make_hunk(1, 5), make_hunk(50, 5)]);
let mut app = build_app_with_files(vec![file], 100);
let gap_id = GapId {
file_idx: 0,
hunk_idx: 1,
};
app.expand_gap(gap_id.clone(), ExpandDirection::Down, Some(20))
.unwrap();
app.expand_gap(gap_id.clone(), ExpandDirection::Down, Some(20))
.unwrap();
let content = app.expanded_top.get(&gap_id).unwrap();
assert_eq!(content.len(), 40);
assert_eq!(content[0].new_lineno, Some(6));
assert_eq!(content[39].new_lineno, Some(45));
}
#[test]
fn should_prepend_on_subsequent_up_expand() {
let file = make_file_with_hunks("test.rs", vec![make_hunk(1, 5), make_hunk(50, 5)]);
let mut app = build_app_with_files(vec![file], 100);
let gap_id = GapId {
file_idx: 0,
hunk_idx: 1,
};
app.expand_gap(gap_id.clone(), ExpandDirection::Up, Some(10))
.unwrap();
app.expand_gap(gap_id.clone(), ExpandDirection::Up, Some(10))
.unwrap();
let content = app.expanded_bottom.get(&gap_id).unwrap();
assert_eq!(content.len(), 20);
assert_eq!(content[0].new_lineno, Some(30));
assert_eq!(content[19].new_lineno, Some(49));
}
#[test]
fn should_cap_at_gap_boundaries() {
let file = make_file_with_hunks("test.rs", vec![make_hunk(51, 5)]);
let mut app = build_app_with_files(vec![file], 100);
let gap_id = GapId {
file_idx: 0,
hunk_idx: 0,
};
app.expand_gap(gap_id.clone(), ExpandDirection::Up, Some(40))
.unwrap();
app.expand_gap(gap_id.clone(), ExpandDirection::Up, Some(20))
.unwrap();
let content = app.expanded_bottom.get(&gap_id).unwrap();
assert_eq!(content.len(), 50);
assert_eq!(content[0].new_lineno, Some(1));
}
#[test]
fn should_show_up_expander_for_top_of_file_partial() {
let file = make_file_with_hunks("test.rs", vec![make_hunk(51, 5)]);
let mut app = build_app_with_files(vec![file], 100);
let gap_id = GapId {
file_idx: 0,
hunk_idx: 0,
};
app.expand_gap(gap_id.clone(), ExpandDirection::Up, Some(20))
.unwrap();
let expander_count = app
.line_annotations
.iter()
.filter(|a| matches!(a, AnnotatedLine::Expander { gap_id: g, direction: ExpandDirection::Up } if *g == gap_id))
.count();
assert_eq!(expander_count, 1);
let hidden_count = app
.line_annotations
.iter()
.filter(|a| matches!(a, AnnotatedLine::HiddenLines { gap_id: g, .. } if *g == gap_id))
.count();
assert_eq!(hidden_count, 1, "should show hidden lines count");
let expanded_count = app
.line_annotations
.iter()
.filter(
|a| matches!(a, AnnotatedLine::ExpandedContext { gap_id: g, .. } if *g == gap_id),
)
.count();
assert_eq!(expanded_count, 20);
}
#[test]
fn should_not_show_expander_when_fully_expanded() {
let file = make_file_with_hunks("test.rs", vec![make_hunk(51, 5)]);
let mut app = build_app_with_files(vec![file], 100);
let gap_id = GapId {
file_idx: 0,
hunk_idx: 0,
};
app.expand_gap(gap_id.clone(), ExpandDirection::Both, None)
.unwrap();
let expander_count = app
.line_annotations
.iter()
.filter(|a| matches!(a, AnnotatedLine::Expander { gap_id: g, .. } if *g == gap_id))
.count();
assert_eq!(expander_count, 0);
}
#[test]
fn should_show_merged_expander_for_small_between_hunk_gap() {
let file = make_file_with_hunks("test.rs", vec![make_hunk(1, 5), make_hunk(21, 5)]);
let app = build_app_with_files(vec![file], 100);
let gap_id = GapId {
file_idx: 0,
hunk_idx: 1,
};
let both_count = app
.line_annotations
.iter()
.filter(|a| matches!(a, AnnotatedLine::Expander { gap_id: g, direction: ExpandDirection::Both } if *g == gap_id))
.count();
assert_eq!(both_count, 1, "small gap should show merged ↕ expander");
}
#[test]
fn should_show_split_expanders_for_large_between_hunk_gap() {
let file = make_file_with_hunks("test.rs", vec![make_hunk(1, 5), make_hunk(36, 5)]);
let app = build_app_with_files(vec![file], 100);
let gap_id = GapId {
file_idx: 0,
hunk_idx: 1,
};
let down_count = app
.line_annotations
.iter()
.filter(|a| matches!(a, AnnotatedLine::Expander { gap_id: g, direction: ExpandDirection::Down } if *g == gap_id))
.count();
let up_count = app
.line_annotations
.iter()
.filter(|a| matches!(a, AnnotatedLine::Expander { gap_id: g, direction: ExpandDirection::Up } if *g == gap_id))
.count();
let hidden_count = app
.line_annotations
.iter()
.filter(|a| matches!(a, AnnotatedLine::HiddenLines { gap_id: g, .. } if *g == gap_id))
.count();
assert_eq!(down_count, 1);
assert_eq!(up_count, 1);
assert_eq!(hidden_count, 1);
}
#[test]
fn should_expand_gap_in_correct_file_not_adjacent_file() {
let file0 = make_file_with_hunks("a.rs", vec![make_hunk(31, 5)]);
let file1 = make_file_with_hunks("b.rs", vec![make_hunk(21, 5)]);
let mut app = build_app_with_files(vec![file0, file1], 100);
let gap_id_file1 = GapId {
file_idx: 1,
hunk_idx: 0,
};
app.expand_gap(gap_id_file1.clone(), ExpandDirection::Up, Some(10))
.unwrap();
let content = app.expanded_bottom.get(&gap_id_file1).unwrap();
assert_eq!(content.len(), 10);
assert_eq!(content[9].new_lineno, Some(20));
let gap_id_file0 = GapId {
file_idx: 0,
hunk_idx: 0,
};
assert!(
!app.expanded_top.contains_key(&gap_id_file0)
&& !app.expanded_bottom.contains_key(&gap_id_file0)
);
}
#[test]
fn should_noop_when_already_fully_expanded() {
let file = make_file_with_hunks("test.rs", vec![make_hunk(11, 5)]);
let mut app = build_app_with_files(vec![file], 100);
let gap_id = GapId {
file_idx: 0,
hunk_idx: 0,
};
app.expand_gap(gap_id.clone(), ExpandDirection::Both, None)
.unwrap();
let len_before = app.expanded_top.get(&gap_id).unwrap().len();
app.expand_gap(gap_id.clone(), ExpandDirection::Up, Some(20))
.unwrap();
let len_after = app.expanded_top.get(&gap_id).unwrap().len();
assert_eq!(len_before, len_after);
}
#[test]
fn should_expand_small_gap_fully_even_with_large_limit() {
let file = make_file_with_hunks("test.rs", vec![make_hunk(6, 5)]);
let mut app = build_app_with_files(vec![file], 100);
let gap_id = GapId {
file_idx: 0,
hunk_idx: 0,
};
app.expand_gap(gap_id.clone(), ExpandDirection::Up, Some(20))
.unwrap();
let content = app.expanded_bottom.get(&gap_id).unwrap();
assert_eq!(content.len(), 5);
let expander_count = app
.line_annotations
.iter()
.filter(|a| matches!(a, AnnotatedLine::Expander { gap_id: g, .. } if *g == gap_id))
.count();
assert_eq!(expander_count, 0);
}
#[test]
fn should_merge_to_both_when_remaining_drops_below_batch() {
let file = make_file_with_hunks("test.rs", vec![make_hunk(1, 5), make_hunk(36, 5)]);
let mut app = build_app_with_files(vec![file], 100);
let gap_id = GapId {
file_idx: 0,
hunk_idx: 1,
};
app.expand_gap(gap_id.clone(), ExpandDirection::Down, Some(20))
.unwrap();
let both_count = app
.line_annotations
.iter()
.filter(|a| matches!(a, AnnotatedLine::Expander { gap_id: g, direction: ExpandDirection::Both } if *g == gap_id))
.count();
assert_eq!(both_count, 1, "should merge to ↕ when <20 remaining");
}
}