use super::*;
impl App {
pub(in crate::app) fn initial_commit_range(
start: CommitSelectionStart,
n: usize,
) -> Option<(usize, usize)> {
match (start, n) {
(_, 0) => None,
(CommitSelectionStart::Oldest, n) => Some((n - 1, n - 1)),
(CommitSelectionStart::All, n) => Some((0, n - 1)),
}
}
pub(in crate::app) fn is_strict_commit_selection(
range: Option<(usize, usize)>,
total: usize,
) -> bool {
range.is_some_and(|(start, end)| {
total > 0 && start <= end && end < total && (start > 0 || end + 1 < total)
})
}
pub(in crate::app) fn apply_pr_commit_selector(
&mut self,
commits: Vec<crate::forge::traits::PullRequestCommit>,
review_metadata: crate::forge::traits::PullRequestReviewMetadata,
) -> Option<String> {
self.pr_last_reviewed_commit_index = None;
if commits.len() <= 1 {
return None;
}
let since_last_review = commits_since_last_review_selection(&commits, &review_metadata);
self.set_pr_last_reviewed_commit_from_metadata(&commits, &review_metadata);
self.pr_commits = commits.clone();
let mapped: Vec<CommitInfo> = commits.iter().map(pr_commit_to_commit_info).collect();
self.range_diff_files = Some(self.diff_files.clone());
self.commit_list = mapped.clone();
self.commit_list_cursor = 0;
self.commit_list_scroll_offset = 0;
self.visible_commit_count = mapped.len();
self.has_more_commit = false;
self.show_commit_selector = true;
let mut range = (0, mapped.len() - 1);
let mut auto_scoped_since_last_review = false;
let mut since_last_review_message = None;
if let Some(persisted) = self.session.commit_selection_range
&& persisted.1 < mapped.len()
&& persisted.0 <= persisted.1
{
range = persisted;
} else if self.commit_selection_start == CommitSelectionStart::Oldest {
if let Some(oldest) =
Self::initial_commit_range(CommitSelectionStart::Oldest, mapped.len())
{
range = oldest;
}
} else if self.session.commit_selection_range.is_none()
&& let Some(selection) = since_last_review.as_ref()
{
if let Some(selected_range) = selection.range {
range = selected_range;
auto_scoped_since_last_review = true;
}
since_last_review_message = Some(selection.message.clone());
}
self.commit_selection_range = Some(range);
self.review_commits = mapped;
if let Some(message) = since_last_review_message {
if auto_scoped_since_last_review
&& Self::is_strict_commit_selection(Some(range), self.pr_commits.len())
{
self.focused_panel = FocusedPanel::CommitSelector;
self.commit_list_cursor = self.pr_commits.len().saturating_sub(1);
self.commit_list_scroll_offset = self.commit_list_cursor.saturating_sub(5);
}
return Some(message);
}
None
}
pub fn commit_list_idx_at_screen_row(&self, screen_row: u16) -> Option<usize> {
let inner = self.commit_list_inner_area?;
if screen_row < inner.y || screen_row >= inner.y + inner.height {
return None;
}
let rel = (screen_row - inner.y) as usize;
let display_idx = self.commit_list_scroll_offset + rel;
match self.input_mode {
InputMode::CommitSelect => {
let total = self.visible_commit_count + usize::from(self.can_show_more_commits());
(display_idx < total).then_some(display_idx)
}
_ => {
let total = self.review_commits.len();
(display_idx < total).then_some(self.commit_data_index(display_idx))
}
}
}
pub fn commits_ascending(&self) -> bool {
matches!(self.commit_order, CommitOrder::Ascending)
}
pub fn commit_data_index(&self, index: usize) -> usize {
let n = self.review_commits.len();
if self.commits_ascending() && n > 0 {
n - 1 - index.min(n - 1)
} else {
index
}
}
pub fn commit_selection_summary(&self) -> Option<String> {
let total = self.review_commits.len();
let (start, end) = self.commit_selection_range?;
let selected = end.saturating_sub(start) + 1;
if total <= 1 || selected >= total {
return None;
}
if start == end {
Some(format!(
"commit {}/{}",
self.commit_data_index(start) + 1,
total
))
} else {
Some(format!("{selected} of {total} commits"))
}
}
pub fn enter_target_selector(&mut self, initial_tab: TargetTab) -> Result<()> {
if !self.review_commits.is_empty() {
self.saved_inline_selection = self.commit_selection_range;
}
let highlighter = self.theme.syntax_highlighter();
let change_status = Self::get_change_status_with_ignore(
self.vcs.as_ref(),
&self.vcs_info.root_path,
highlighter,
self.path_filter.as_deref(),
)?;
let has_staged_changes = change_status.staged;
let has_unstaged_changes = change_status.unstaged;
let commits = self.vcs.get_recent_commits(0, VISIBLE_COMMIT_COUNT)?;
let no_local_targets = commits.is_empty() && !has_staged_changes && !has_unstaged_changes;
if no_local_targets && initial_tab == TargetTab::Local {
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;
self.pr_tab = PullRequestsTab::new(self.forge_repository.clone());
self.pr_filter_draft = None;
self.pr_load_rx = None;
self.target_tab = initial_tab;
if initial_tab == TargetTab::PullRequests {
self.on_target_tab_entered();
}
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_for_source()?;
}
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 {
self.session.add_diff_file(file);
}
self.sort_files_by_directory(true);
self.expand_all_dirs();
}
Err(_) => {
self.set_message("No staged or unstaged changes");
}
}
}
Ok(())
}
pub fn cycle_target_tab(&mut self, _forward: bool) {
let next = match self.target_tab {
TargetTab::Local => TargetTab::PullRequests,
TargetTab::PullRequests => TargetTab::Local,
};
self.target_tab = next;
if next == TargetTab::PullRequests {
self.on_target_tab_entered();
} else {
self.pr_filter_draft = None;
}
}
fn on_target_tab_entered(&mut self) {
if let Some((repo, scope)) = self.pr_tab.start_initial_load() {
let override_repo = self.repo_url_override.clone();
let skip_resolution = self.canonical_resolved;
self.spawn_pr_initial_load(repo, override_repo, skip_resolution, scope);
}
}
pub fn has_inline_commit_selector(&self) -> bool {
self.show_commit_selector && self.has_review_commits()
}
pub fn has_review_commits(&self) -> bool {
self.review_commits.len() > 1 && !matches!(&self.diff_source, DiffSource::WorkingTree)
}
pub fn toggle_commit_selector(&mut self) {
let visible = !self.show_commit_selector;
self.show_commit_selector = visible;
if !visible && self.focused_panel == FocusedPanel::CommitSelector {
self.focused_panel = FocusedPanel::Diff;
}
let status = if visible { "visible" } else { "hidden" };
self.set_message(format!("Commit selector: {status}"));
}
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 toggle_commit_selection_and_advance(&mut self) {
let cursor = self.commit_list_cursor;
let was_selected = self.is_commit_selected(cursor);
self.toggle_commit_selection();
let now_selected = self.is_commit_selected(cursor);
if was_selected || !now_selected {
return;
}
if let Some((_, end)) = self.commit_selection_range {
while self.commit_list_cursor <= end {
let before = self.commit_list_cursor;
self.commit_select_down();
if self.commit_list_cursor == before {
return;
}
}
}
}
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)) => {
let all_commits_selected =
self.commit_list.len() > 1 && start == 0 && end == self.commit_list.len() - 1;
if all_commits_selected {
self.commit_selection_range = Some((cursor, cursor));
return;
}
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 - 1));
}
} 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(in crate::app) fn selected_commit_set(&self) -> Option<std::collections::HashSet<String>> {
let (start, end) = self.commit_selection_range?;
if start > end || self.review_commits.is_empty() {
return Some(std::collections::HashSet::new());
}
let end = end.min(self.review_commits.len().saturating_sub(1));
let set: std::collections::HashSet<String> = (start..=end)
.filter_map(|i| self.review_commits.get(i))
.filter(|c| !Self::is_special_commit(c))
.map(|c| c.id.clone())
.collect();
Some(set)
}
pub fn comment_visible(&self, comment: &crate::model::Comment) -> bool {
Self::comment_visible_with(comment, self.selected_commit_set().as_ref())
}
pub fn comment_visible_with(
comment: &crate::model::Comment,
commit_set: Option<&std::collections::HashSet<String>>,
) -> bool {
match (&comment.commit_id, commit_set) {
(None, _) => true,
(Some(_), None) => true,
(Some(sha), Some(set)) => set.contains(sha),
}
}
pub(in crate::app) fn commit_id_for_new_comment(&self) -> Option<String> {
let (start, end) = self.commit_selection_range?;
if start != end {
return None;
}
self.review_commits
.get(start)
.filter(|c| !Self::is_special_commit(c))
.map(|c| c.id.clone())
}
pub(in crate::app) fn set_pr_last_reviewed_commit_from_metadata(
&mut self,
commits: &[crate::forge::traits::PullRequestCommit],
review_metadata: &crate::forge::traits::PullRequestReviewMetadata,
) {
self.pr_last_reviewed_commit_index = if commits.len() > 1 {
commits_since_last_review_selection(commits, review_metadata)
.map(|selection| selection.reviewed_index)
} else {
None
};
}
pub(in crate::app) fn mark_pr_commits_reviewed_through(&mut self, commit_id: &str) {
if !matches!(&self.diff_source, DiffSource::PullRequest(_)) {
return;
}
if let Some(index) = self
.pr_commits
.iter()
.position(|commit| commit.oid == commit_id)
{
self.pr_last_reviewed_commit_index = Some(index);
}
}
pub fn is_commit_reviewed_by_viewer(&self, index: usize) -> bool {
matches!(&self.diff_source, DiffSource::PullRequest(_))
&& index < self.review_commits.len()
&& self
.pr_last_reviewed_commit_index
.is_some_and(|reviewed_index| index >= reviewed_index)
}
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 selection = match self.commit_selection_range {
Some((start, end)) => format!(
"range={start}..={end}, rows={}",
end.saturating_sub(start) + 1
),
None => "range=none, rows=0".to_string(),
};
crate::profile::time_with(
"commit_select.confirm_selection",
|| self.confirm_commit_selection_inner(),
|result| format!("{selection}, {}", profile_unit_result(result)),
)
}
fn confirm_commit_selection_inner(&mut self) -> Result<()> {
let (start, end) = match self.commit_selection_range {
Some(range) => range,
None => {
let cursor = self.commit_list_cursor;
(cursor, cursor)
}
};
let selected_commits: Vec<CommitInfo> = (start..=end)
.rev()
.filter_map(|i| self.commit_list.get(i))
.cloned()
.collect();
if selected_commits.is_empty() {
self.set_message("Select at least one commit");
return Ok(());
}
let selected_staged = selected_commits.iter().any(Self::is_staged_commit);
let selected_unstaged = selected_commits.iter().any(Self::is_unstaged_commit);
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() {
return self.load_staged_unstaged_and_commits_selection(selected_ids, selected_commits);
}
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,
&ResolvedRevisionRange::from_commit_ids(&selected_ids, RevisionDiffTarget::CommitList),
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 {
self.session.add_diff_file(file);
}
self.reset_persisted_session_tracking();
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.pr_commits.clear();
self.pr_last_reviewed_commit_index = None;
self.review_commits = selected_commits.iter().rev().cloned().collect();
self.range_diff_files = Some(self.diff_files.clone());
self.commit_list = self.review_commits.clone();
let range =
Self::initial_commit_range(self.commit_selection_start, self.review_commits.len());
self.commit_selection_range = range;
self.commit_list_cursor = range.map(|(start, _)| start).unwrap_or(0);
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;
if Self::is_strict_commit_selection(self.commit_selection_range, self.review_commits.len())
{
self.reload_inline_selection()?;
} else {
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,
&ResolvedRevisionRange::from_commit_ids(
&selected_ids,
RevisionDiffTarget::CommitList,
),
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(())
}
pub fn reload_inline_selection_for_source(&mut self) -> Result<()> {
if matches!(self.diff_source, DiffSource::PullRequest(_)) {
self.persist_pr_commit_selection_range();
self.reload_pr_inline_selection();
Ok(())
} else {
self.reload_inline_selection()
}
}
}