ghr-cli 0.7.8

A fast terminal dashboard for GitHub pull requests, issues, and notifications.
use super::*;

impl AppState {
    pub(super) fn mention_candidate_view_for_target(
        &self,
        target: MentionTarget,
    ) -> Option<MentionCandidateView> {
        let context = self.active_mention_context()?;
        if context.target != target {
            return None;
        }
        let candidates = self.mention_candidate_matches(&context);
        let user_search_key = mention_user_search_key(&context.query);
        let selected = if candidates.is_empty() {
            0
        } else {
            self.mention_selected.min(candidates.len() - 1)
        };
        Some(MentionCandidateView {
            repo: context.repo.clone(),
            query: context.query,
            candidates,
            selected,
            loading: self.mention_candidate_loading_repos.contains(&context.repo)
                || user_search_key
                    .as_ref()
                    .is_some_and(|key| self.mention_user_search_loading_queries.contains(key)),
            error: user_search_key
                .and_then(|key| self.mention_user_search_errors.get(&key).cloned())
                .or_else(|| self.mention_candidate_errors.get(&context.repo).cloned()),
        })
    }

    pub(super) fn ensure_mention_candidates_for_active_editor(
        &mut self,
        store: Option<&SnapshotStore>,
        tx: &UnboundedSender<AppMsg>,
    ) {
        let Some(context) = self.active_mention_context() else {
            self.mention_selected = 0;
            return;
        };
        let count = self.mention_candidate_matches(&context).len();
        if count == 0 {
            self.mention_selected = 0;
        } else {
            self.mention_selected = self.mention_selected.min(count - 1);
        }

        self.ensure_mention_user_search_candidates(&context, tx);
        if !self.assignee_suggestions_cache.contains_key(&context.repo)
            && !self
                .mention_candidate_loading_repos
                .contains(context.repo.as_str())
        {
            self.mention_candidate_errors.remove(&context.repo);
            if start_assignee_suggestions_load(context.repo.clone(), store.cloned(), tx.clone()) {
                self.mention_candidate_loading_repos
                    .insert(context.repo.clone());
                self.status = format!("loading mention candidates for {}", context.repo);
            }
        }
    }

    pub(super) fn handle_active_mention_key(&mut self, key: KeyEvent) -> bool {
        if key
            .modifiers
            .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER)
        {
            return false;
        }
        let Some(context) = self.active_mention_context() else {
            self.mention_selected = 0;
            return false;
        };
        let candidates = self.mention_candidate_matches(&context);
        if candidates.is_empty() {
            self.mention_selected = 0;
            return false;
        }

        match key.code {
            KeyCode::Up => {
                self.mention_selected = move_wrapping(self.mention_selected, candidates.len(), -1);
                true
            }
            KeyCode::Down => {
                self.mention_selected = move_wrapping(self.mention_selected, candidates.len(), 1);
                true
            }
            KeyCode::Enter | KeyCode::Tab => {
                let login = candidates[self.mention_selected.min(candidates.len() - 1)].clone();
                self.replace_active_mention(&context, &login);
                self.status = format!("inserted mention @{login}");
                true
            }
            _ => false,
        }
    }

    fn active_mention_context(&self) -> Option<MentionContext> {
        if self.comment_dialog.is_some() {
            return self.mention_context_for_target(MentionTarget::Comment);
        }
        if self.review_submit_dialog.is_some() {
            return self.mention_context_for_target(MentionTarget::ReviewSubmit);
        }
        if let Some(dialog) = &self.issue_dialog {
            return match dialog.field {
                IssueDialogField::Title => {
                    self.mention_context_for_target(MentionTarget::IssueTitle)
                }
                IssueDialogField::Body => self.mention_context_for_target(MentionTarget::IssueBody),
                _ => None,
            };
        }
        if let Some(dialog) = &self.pr_create_dialog {
            return match dialog.field {
                PrCreateField::Title => {
                    self.mention_context_for_target(MentionTarget::PrCreateTitle)
                }
                PrCreateField::Body => self.mention_context_for_target(MentionTarget::PrCreateBody),
            };
        }
        if let Some(dialog) = &self.item_edit_dialog {
            return match dialog.field {
                ItemEditField::Title => {
                    self.mention_context_for_target(MentionTarget::ItemEditTitle)
                }
                ItemEditField::Body => self.mention_context_for_target(MentionTarget::ItemEditBody),
                _ => None,
            };
        }
        None
    }

    fn mention_context_for_target(&self, target: MentionTarget) -> Option<MentionContext> {
        match target {
            MentionTarget::Comment => {
                let item = self.current_item()?;
                let dialog = self.comment_dialog.as_ref()?;
                mention_context_from_editor(target, &item.repo, &dialog.body)
            }
            MentionTarget::ReviewSubmit => {
                let dialog = self.review_submit_dialog.as_ref()?;
                mention_context_from_editor(target, &dialog.item.repo, &dialog.body)
            }
            MentionTarget::IssueTitle => {
                let dialog = self.issue_dialog.as_ref()?;
                mention_context_from_editor(target, dialog.repo.text(), &dialog.title)
            }
            MentionTarget::IssueBody => {
                let dialog = self.issue_dialog.as_ref()?;
                mention_context_from_editor(target, dialog.repo.text(), &dialog.body)
            }
            MentionTarget::PrCreateTitle => {
                let dialog = self.pr_create_dialog.as_ref()?;
                mention_context_from_editor(target, &dialog.repo, &dialog.title)
            }
            MentionTarget::PrCreateBody => {
                let dialog = self.pr_create_dialog.as_ref()?;
                mention_context_from_editor(target, &dialog.repo, &dialog.body)
            }
            MentionTarget::ItemEditTitle => {
                let dialog = self.item_edit_dialog.as_ref()?;
                mention_context_from_editor(target, &dialog.item.repo, &dialog.title)
            }
            MentionTarget::ItemEditBody => {
                let dialog = self.item_edit_dialog.as_ref()?;
                mention_context_from_editor(target, &dialog.item.repo, &dialog.body)
            }
        }
    }

    fn mention_candidate_matches(&self, context: &MentionContext) -> Vec<String> {
        let mut candidates =
            global_search_author_candidates_from_sections(&self.sections, Some(&context.repo));
        candidates = merge_candidate_lists(
            candidates,
            self.sections
                .iter()
                .flat_map(|section| section.items.iter())
                .filter(|item| item.repo.eq_ignore_ascii_case(&context.repo))
                .flat_map(|item| item.assignees.clone()),
        );
        if let Some(assignees) = self.assignee_suggestions_cache.get(&context.repo) {
            candidates = merge_candidate_lists(candidates, assignees.clone());
        }
        if let Some(reviewers) = self.reviewer_suggestions_cache.get(&context.repo) {
            candidates = merge_candidate_lists(candidates, reviewers.clone());
        }
        candidates = merge_candidate_lists(
            candidates,
            self.loaded_comment_author_candidates_for_repo(&context.repo),
        );
        if let Some(key) = mention_user_search_key(&context.query)
            && let Some(users) = self.mention_user_search_cache.get(&key)
        {
            candidates = merge_candidate_lists(candidates, users.clone());
        }

        let query = context.query.to_ascii_lowercase();
        candidates
            .into_iter()
            .filter(|login| {
                query.is_empty() || login.to_ascii_lowercase().starts_with(query.as_str())
            })
            .collect()
    }

    fn loaded_comment_author_candidates_for_repo(&self, repo: &str) -> Vec<String> {
        let item_ids = self
            .sections
            .iter()
            .flat_map(|section| section.items.iter())
            .filter(|item| item.repo.eq_ignore_ascii_case(repo))
            .map(|item| item.id.clone())
            .collect::<HashSet<_>>();
        merge_candidate_lists(
            Vec::new(),
            self.details.iter().flat_map(|(item_id, state)| {
                if !item_ids.contains(item_id) {
                    return Vec::new();
                }
                match state {
                    DetailState::Loaded(comments) => comments
                        .iter()
                        .map(|comment| comment.author.clone())
                        .collect::<Vec<_>>(),
                    _ => Vec::new(),
                }
            }),
        )
    }

    fn ensure_mention_user_search_candidates(
        &mut self,
        context: &MentionContext,
        tx: &UnboundedSender<AppMsg>,
    ) {
        let Some(key) = mention_user_search_key(&context.query) else {
            return;
        };
        if self.mention_user_search_cache.contains_key(&key)
            || self.mention_user_search_loading_queries.contains(&key)
        {
            return;
        }
        self.mention_user_search_errors.remove(&key);
        if start_mention_user_search_load(key.clone(), tx.clone()) {
            self.mention_user_search_loading_queries.insert(key);
        }
    }

    fn replace_active_mention(&mut self, context: &MentionContext, login: &str) {
        let replacement = format!("@{login} ");
        match context.target {
            MentionTarget::Comment => {
                if let Some(dialog) = &mut self.comment_dialog {
                    dialog
                        .body
                        .replace_range(context.trigger_start, context.cursor, &replacement);
                }
            }
            MentionTarget::ReviewSubmit => {
                if let Some(dialog) = &mut self.review_submit_dialog {
                    dialog
                        .body
                        .replace_range(context.trigger_start, context.cursor, &replacement);
                }
            }
            MentionTarget::IssueTitle => {
                if let Some(dialog) = &mut self.issue_dialog {
                    dialog
                        .title
                        .replace_range(context.trigger_start, context.cursor, &replacement);
                }
            }
            MentionTarget::IssueBody => {
                if let Some(dialog) = &mut self.issue_dialog {
                    dialog
                        .body
                        .replace_range(context.trigger_start, context.cursor, &replacement);
                }
            }
            MentionTarget::PrCreateTitle => {
                if let Some(dialog) = &mut self.pr_create_dialog {
                    dialog
                        .title
                        .replace_range(context.trigger_start, context.cursor, &replacement);
                }
            }
            MentionTarget::PrCreateBody => {
                if let Some(dialog) = &mut self.pr_create_dialog {
                    dialog
                        .body
                        .replace_range(context.trigger_start, context.cursor, &replacement);
                }
            }
            MentionTarget::ItemEditTitle => {
                if let Some(dialog) = &mut self.item_edit_dialog {
                    dialog
                        .title
                        .replace_range(context.trigger_start, context.cursor, &replacement);
                }
            }
            MentionTarget::ItemEditBody => {
                if let Some(dialog) = &mut self.item_edit_dialog {
                    dialog
                        .body
                        .replace_range(context.trigger_start, context.cursor, &replacement);
                }
            }
        }
        self.mention_selected = 0;
    }
}

fn mention_context_from_editor(
    target: MentionTarget,
    repo: &str,
    editor: &EditorText,
) -> Option<MentionContext> {
    let repo = repo.trim();
    if !repo.contains('/') {
        return None;
    }
    let cursor = editor.cursor_byte();
    let (trigger_start, query) = mention_query_at_cursor(editor.text(), cursor)?;
    Some(MentionContext {
        target,
        repo: repo.to_string(),
        query,
        trigger_start,
        cursor,
    })
}

pub(super) fn mention_query_at_cursor(text: &str, cursor: usize) -> Option<(usize, String)> {
    let cursor = clamp_text_cursor(text, cursor);
    let before = &text[..cursor];
    for (index, ch) in before.char_indices().rev() {
        if ch == '@' {
            return Some((index, before[index + ch.len_utf8()..].to_string()));
        }
        if !is_github_login_char(ch) {
            return None;
        }
    }
    None
}

pub(super) fn mention_user_search_key(query: &str) -> Option<String> {
    let query = query.trim().trim_start_matches('@').to_ascii_lowercase();
    (!query.is_empty()).then_some(query)
}

fn is_github_login_char(ch: char) -> bool {
    ch.is_ascii_alphanumeric() || ch == '-'
}