quinjet 0.0.50

A fast, live, keyboard-first Git source-control interface for the terminal
#[cfg_attr(not(test), expect(clippy::wildcard_imports, reason = "shared"))]
use super::*;

impl App {
    pub(super) fn reset_pull_request_review(&mut self) {
        self.pull_request_review = PullRequestReviewSnapshot::default();
        self.pull_request_review_loading = false;
        self.pull_request_review_mutating = false;
        self.pull_request_review_error = None;
        self.pull_request_review_cursor = None;
        self.pull_request_review_line_threads.clear();
        self.pull_request_review_generation = self.pull_request_review_generation.wrapping_add(1);
    }

    pub(super) fn decorate_pull_request_review(&mut self) {
        if !self.review_document_active() {
            return;
        }
        let Some(path) = self.pull_request_single_file.as_ref() else {
            return;
        };
        self.document
            .lines
            .retain(|line| line.kind != DiffLineKind::Review);
        self.pull_request_review_line_threads.clear();
        let threads = self
            .pull_request_review
            .threads
            .iter()
            .filter(|thread| &thread.path == path)
            .cloned()
            .collect::<Vec<_>>();
        if threads.is_empty() {
            self.invalidate_diff_rows();
            return;
        }
        let source = std::mem::take(&mut self.document.lines);
        let mut lines = Vec::with_capacity(source.len().saturating_add(threads.len()));
        for line in source {
            let anchored = threads
                .iter()
                .filter(|thread| review_thread_matches_line(thread, &line))
                .collect::<Vec<_>>();
            lines.push(line);
            for thread in anchored {
                let start = lines.len();
                lines.extend(review_thread_lines(thread));
                for index in start..lines.len() {
                    drop(
                        self.pull_request_review_line_threads
                            .insert(index, thread.id.clone()),
                    );
                }
            }
        }
        self.document.lines = lines;
        self.invalidate_diff_rows();
    }

    pub(super) fn request_pull_request_review(
        &mut self,
        refresh: bool,
        effects: &mut Vec<AppEffect>,
    ) {
        if self.pull_request_review_loading
            || (!refresh && !self.pull_request_review.head_oid.is_empty())
        {
            return;
        }
        let Some(pull_request) = self.pull_request.clone() else {
            return;
        };
        self.pull_request_review_generation = self.pull_request_review_generation.wrapping_add(1);
        self.pull_request_review_loading = true;
        self.pull_request_review_mutating = false;
        effects.push(AppEffect::Git(Box::new(
            WorkerCommand::LoadPullRequestReview {
                generation: self.pull_request_review_generation,
                pull_request: Box::new(pull_request),
            },
        )));
    }

    pub(super) fn queue_pull_request_review_operation(
        &mut self,
        operation: PullRequestReviewOperation,
        effects: &mut Vec<AppEffect>,
    ) {
        if self.pull_request_review_loading {
            return;
        }
        let Some(pull_request) = self.pull_request.clone() else {
            return;
        };
        self.pull_request_review_generation = self.pull_request_review_generation.wrapping_add(1);
        self.pull_request_review_loading = true;
        self.pull_request_review_mutating = true;
        effects.push(AppEffect::Git(Box::new(
            WorkerCommand::OperatePullRequestReview {
                generation: self.pull_request_review_generation,
                pull_request: Box::new(pull_request),
                operation,
            },
        )));
    }

    pub(crate) fn review_surface_active(&self) -> bool {
        self.review_document_active() && self.focus == Focus::Content
    }

    fn review_document_active(&self) -> bool {
        self.view == View::PullRequests
            && self.pull_request_section == PullRequestSection::Files
            && self.pull_request_file_view == PullRequestFileView::SingleFile
            && self.pull_request_single_file.is_some()
    }

    fn review_anchors(&self) -> Vec<(usize, PullRequestReviewAnchor)> {
        let Some(path) = self.pull_request_single_file.as_ref() else {
            return Vec::new();
        };
        self.document
            .lines
            .iter()
            .enumerate()
            .filter_map(|(index, line)| {
                let (number, side) = match line.kind {
                    DiffLineKind::Removed => (line.old_line, PullRequestReviewSide::Left),
                    DiffLineKind::Added | DiffLineKind::Context => {
                        (line.new_line, PullRequestReviewSide::Right)
                    }
                    _ => return None,
                };
                Some((
                    index,
                    PullRequestReviewAnchor {
                        path: path.clone(),
                        line: number?,
                        side,
                    },
                ))
            })
            .collect()
    }

    pub(super) fn move_review_cursor(&mut self, direction: isize) {
        let anchors = self.review_anchors();
        if anchors.is_empty() {
            self.pull_request_review_cursor = None;
            return;
        }
        let current = self
            .pull_request_review_cursor
            .as_ref()
            .and_then(|cursor| anchors.iter().position(|(_, anchor)| anchor == cursor));
        let next = match (current, direction.is_negative()) {
            (Some(index), true) => index.saturating_sub(1),
            (Some(index), false) => index.saturating_add(1).min(anchors.len() - 1),
            (None, true) => anchors.len() - 1,
            (None, false) => 0,
        };
        let Some((line_index, anchor)) = anchors.get(next) else {
            return;
        };
        self.pull_request_review_cursor = Some(anchor.clone());
        self.content_scroll = line_index.saturating_sub(2);
    }

    fn ensure_review_cursor(&mut self) -> Option<PullRequestReviewAnchor> {
        let valid = self
            .pull_request_review_cursor
            .as_ref()
            .is_some_and(|cursor| {
                self.review_anchors()
                    .iter()
                    .any(|(_, anchor)| anchor == cursor)
            });
        if !valid {
            self.pull_request_review_cursor = None;
            self.move_review_cursor(1);
        }
        self.pull_request_review_cursor.clone()
    }

    pub(super) fn selected_review_thread(&self) -> Option<&PullRequestReviewThread> {
        let cursor = self.pull_request_review_cursor.as_ref()?;
        self.pull_request_review.threads.iter().find(|thread| {
            thread.path == cursor.path
                && thread.side == cursor.side
                && thread.line.or(thread.original_line) == Some(cursor.line)
        })
    }

    pub(super) fn open_review_comment(&mut self, file: bool) {
        let target = if file {
            let Some(path) = self.pull_request_single_file.clone() else {
                return;
            };
            PullRequestReviewTarget::File(path)
        } else {
            let Some(anchor) = self.ensure_review_cursor() else {
                return;
            };
            PullRequestReviewTarget::Line(anchor)
        };
        self.modal = Some(Modal::PullRequestReviewComment {
            input: TextBuffer::default(),
            target,
        });
    }

    pub(super) fn open_review_reply(&mut self) {
        let Some(thread_id) = self
            .selected_review_thread()
            .filter(|thread| thread.viewer_can_reply)
            .map(|thread| thread.id.clone())
        else {
            return;
        };
        self.modal = Some(Modal::PullRequestReviewComment {
            input: TextBuffer::default(),
            target: PullRequestReviewTarget::Reply(thread_id),
        });
    }

    pub(super) fn open_review_thread_actions(&mut self, thread_id: &str) {
        let Some(thread) = self
            .pull_request_review
            .threads
            .iter()
            .find(|thread| thread.id == thread_id)
            .cloned()
        else {
            return;
        };
        self.pull_request_review_cursor =
            thread
                .line
                .or(thread.original_line)
                .map(|line| PullRequestReviewAnchor {
                    path: thread.path.clone(),
                    line,
                    side: thread.side,
                });
        let mut items = Vec::new();
        if thread.viewer_can_reply {
            items.push(PullRequestReviewThreadAction::Reply {
                thread_id: thread.id.clone(),
            });
        }
        if let Some(comment) = thread.comments.last() {
            items.push(PullRequestReviewThreadAction::CopyComment {
                body: comment.body.clone(),
            });
            if !comment.url.is_empty() {
                items.push(PullRequestReviewThreadAction::OpenComment {
                    url: comment.url.clone(),
                });
            }
            if comment.viewer_can_update {
                items.push(PullRequestReviewThreadAction::EditComment {
                    comment_id: comment.id.clone(),
                    body: comment.body.clone(),
                });
            }
            if comment.viewer_can_delete {
                items.push(PullRequestReviewThreadAction::DeleteComment {
                    comment_id: comment.id.clone(),
                });
            }
        }
        if thread.is_resolved && thread.viewer_can_unresolve {
            items.push(PullRequestReviewThreadAction::Reopen {
                thread_id: thread.id,
            });
        } else if !thread.is_resolved && thread.viewer_can_resolve {
            items.push(PullRequestReviewThreadAction::Resolve {
                thread_id: thread.id,
            });
        }
        if !items.is_empty() {
            self.modal = Some(Modal::PullRequestReviewThreadActions { items, selected: 0 });
        }
    }

    pub(super) fn handle_review_thread_action(
        &mut self,
        action: PullRequestReviewThreadAction,
        effects: &mut Vec<AppEffect>,
    ) {
        match action {
            PullRequestReviewThreadAction::Reply { thread_id } => {
                self.modal = Some(Modal::PullRequestReviewComment {
                    input: TextBuffer::default(),
                    target: PullRequestReviewTarget::Reply(thread_id),
                });
            }
            PullRequestReviewThreadAction::CopyComment { body } => {
                effects.push(AppEffect::Copy(body));
            }
            PullRequestReviewThreadAction::OpenComment { url } => {
                effects.push(AppEffect::Open(OpenTarget::Browser(url)));
            }
            PullRequestReviewThreadAction::EditComment { comment_id, body } => {
                self.modal = Some(Modal::PullRequestReviewComment {
                    input: TextBuffer::new(body),
                    target: PullRequestReviewTarget::Edit { comment_id },
                });
            }
            PullRequestReviewThreadAction::DeleteComment { comment_id } => {
                self.modal = Some(Modal::Confirm {
                    title: "Delete Review Comment?".to_owned(),
                    message: "Permanently delete this review comment?".to_owned(),
                    action: ConfirmAction::PullRequestReview(
                        PullRequestReviewOperation::DeleteComment { comment_id },
                    ),
                });
            }
            PullRequestReviewThreadAction::Resolve { thread_id } => {
                self.queue_pull_request_review_operation(
                    PullRequestReviewOperation::Resolve { thread_id },
                    effects,
                );
            }
            PullRequestReviewThreadAction::Reopen { thread_id } => {
                self.queue_pull_request_review_operation(
                    PullRequestReviewOperation::Unresolve { thread_id },
                    effects,
                );
            }
        }
    }
}

fn review_thread_matches_line(
    thread: &PullRequestReviewThread,
    line: &crate::git::diff::DiffLine,
) -> bool {
    if thread.subject == PullRequestReviewThreadSubject::File {
        return line.kind == DiffLineKind::FileHeader;
    }
    let number = thread.line.or(thread.original_line);
    match thread.side {
        PullRequestReviewSide::Left => number == line.old_line,
        PullRequestReviewSide::Right | PullRequestReviewSide::Unknown => number == line.new_line,
    }
}

fn review_thread_lines(thread: &PullRequestReviewThread) -> Vec<crate::git::diff::DiffLine> {
    let state = if thread.is_resolved {
        "resolved"
    } else if thread.is_outdated {
        "outdated"
    } else {
        "open"
    };
    let mut lines = vec![crate::git::diff::DiffLine::review(format!(
        "review thread ยท {state}"
    ))];
    for comment in &thread.comments {
        for (index, body) in comment.body.lines().enumerate() {
            let prefix = if index == 0 {
                format!("@{}: ", comment.author)
            } else {
                "  ".to_owned()
            };
            lines.push(crate::git::diff::DiffLine::review(format!(
                "{prefix}{body}"
            )));
        }
    }
    lines
}