tuicr 0.20.0

Review AI-generated diffs like a GitHub pull request, right from your terminal.
Documentation
use super::*;

fn find_search_match(
    total_lines: usize,
    start_idx: usize,
    forward: bool,
    include_current: bool,
    pattern: &str,
    mut line_text: impl FnMut(usize) -> Option<String>,
) -> Option<usize> {
    if total_lines == 0 {
        return None;
    }

    let normalized_pattern = pattern.to_lowercase();
    let mut matches = |line_idx| {
        line_text(line_idx).is_some_and(|text| text.to_lowercase().contains(&normalized_pattern))
    };
    let start_idx = start_idx.min(total_lines - 1);
    if forward {
        let first = if include_current {
            start_idx
        } else {
            start_idx.saturating_add(1)
        };
        (first..total_lines).find(|&line_idx| matches(line_idx))
    } else {
        let first = if include_current {
            Some(start_idx)
        } else {
            start_idx.checked_sub(1)
        };
        first.and_then(|line_idx| (0..=line_idx).rev().find(|&line_idx| matches(line_idx)))
    }
}

impl HelpState {
    fn search(&mut self, pattern: &str, forward: bool, include_current: bool) -> bool {
        let start_idx = self.current_match_line.unwrap_or(self.scroll_offset);
        let Some(line) = find_search_match(
            self.searchable_lines.len(),
            start_idx,
            forward,
            include_current,
            pattern,
            |line_idx| self.searchable_lines.get(line_idx).cloned(),
        ) else {
            return false;
        };

        self.current_match_line = Some(line);
        let max_offset = self
            .searchable_lines
            .len()
            .saturating_sub(self.viewport_height);
        self.scroll_offset = line
            .saturating_sub(self.viewport_height / 2)
            .min(max_offset);
        true
    }
}

impl App {
    pub fn search_in_help_from_scroll(&mut self) -> bool {
        let pattern = self.search_buffer.clone();
        if pattern.trim().is_empty() {
            self.set_message("Search pattern is empty");
            return false;
        }

        self.help_state.last_search_pattern = Some(pattern.clone());
        self.help_state.current_match_line = None;
        if self.help_state.search(&pattern, true, true) {
            true
        } else {
            self.set_message(format!("No help matches for \"{pattern}\""));
            false
        }
    }

    pub fn search_next_in_help(&mut self) -> bool {
        let Some(pattern) = self.help_state.last_search_pattern.clone() else {
            self.set_message("No previous help search");
            return false;
        };
        if self.help_state.search(&pattern, true, false) {
            true
        } else {
            self.set_message(format!("No further help matches for \"{pattern}\""));
            false
        }
    }

    pub fn search_prev_in_help(&mut self) -> bool {
        let Some(pattern) = self.help_state.last_search_pattern.clone() else {
            self.set_message("No previous help search");
            return false;
        };
        if self.help_state.search(&pattern, false, false) {
            true
        } else {
            self.set_message(format!("No earlier help matches for \"{pattern}\""));
            false
        }
    }

    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;
        }

        let matched_line = find_search_match(
            total_lines,
            start_idx,
            forward,
            include_current,
            pattern,
            |line_idx| self.line_text_for_search(line_idx),
        );

        let Some(line_idx) = matched_line else {
            self.set_message(format!("No matches for \"{pattern}\""));
            return false;
        };

        self.diff_state.cursor_line = line_idx;
        self.ensure_cursor_visible();
        self.center_cursor();
        self.update_current_file_from_cursor();
        true
    }

    fn line_text_for_search(&self, line_idx: usize) -> Option<String> {
        match self.line_annotations.get(line_idx)? {
            AnnotatedLine::PrInfoLine { line_idx } => {
                let info = self.pr_info.as_ref()?;
                let lines = crate::ui::pr_info_panel::build_pr_info_lines(
                    info,
                    crate::ui::pr_info_panel::pr_info_content_width(self.diff_state.viewport_width),
                    &self.theme,
                );
                lines.get(*line_idx).map(|line| {
                    line.spans
                        .iter()
                        .map(|span| span.content.as_ref())
                        .collect::<String>()
                })
            }
            AnnotatedLine::IssueCommentsHeader => {
                let info = self.pr_info.as_ref()?;
                Some(format!("PR #{} Comments", info.details.number))
            }
            AnnotatedLine::IssueComment { comment_idx } => {
                let info = self.pr_info.as_ref()?;
                let comment = info.issue_comments.get(*comment_idx)?;
                Some(comment.body.clone())
            }
            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::RemoteReviewSummaryLine { summary_idx } => {
                let summary = self.forge_review_summaries.get(*summary_idx)?;
                let author = summary.author.as_deref().unwrap_or("unknown");
                Some(format!("github @{author} {}", summary.body))
            }
            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::RemoteThreadLine { thread_idx } => {
                let thread = self.forge_review_threads.get(*thread_idx)?;
                // Search matches any text in the thread (including replies).
                let mut bodies: Vec<String> =
                    thread.comments.iter().map(|c| c.body.clone()).collect();
                bodies.insert(0, format!("github {}", thread.path));
                Some(bodies.join(" "))
            }
            AnnotatedLine::Spacing => None,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::HelpState;

    fn help_state() -> HelpState {
        HelpState {
            viewport_height: 5,
            searchable_lines: vec![
                "Navigation".to_string(),
                "Scroll down/up".to_string(),
                "Review actions".to_string(),
                "Add line comment".to_string(),
                "Commands".to_string(),
                "Reload comments".to_string(),
                "Toggle this help".to_string(),
            ],
            ..HelpState::default()
        }
    }

    #[test]
    fn should_find_help_text_case_insensitively_and_center_it_in_the_viewport() {
        let mut state = help_state();

        assert!(state.search("COMMENT", true, true));
        assert_eq!(state.current_match_line, Some(3));
        assert_eq!(state.scroll_offset, 1);
    }

    #[test]
    fn should_move_to_next_and_previous_help_matches() {
        let mut state = help_state();
        assert!(state.search("comment", true, true));

        assert!(state.search("comment", true, false));
        assert_eq!(state.current_match_line, Some(5));

        assert!(state.search("comment", false, false));
        assert_eq!(state.current_match_line, Some(3));
    }

    #[test]
    fn should_keep_the_current_help_position_when_no_match_exists() {
        let mut state = help_state();
        state.scroll_offset = 2;

        assert!(!state.search("missing", true, true));
        assert_eq!(state.current_match_line, None);
        assert_eq!(state.scroll_offset, 2);
    }
}