tix-tracker 0.11.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
#[allow(clippy::wildcard_imports)]
use super::*;

impl App {
    /// The mention store for this repository, or `None` when `[mentions] enabled =
    /// false`.
    ///
    /// Delegated rather than re-derived: the store owns one constructor, so the TUI,
    /// the CLI and the write seam cannot disagree about which directory the feature
    /// reads and writes.
    pub(in crate::app) fn mention_store(&self) -> Option<crate::mentions::store::MentionStore> {
        self.store.mention_store()
    }

    /// Recompute the local user's mention candidates and unread count.
    ///
    /// Read-only, and cheap by construction: the candidates are one `git config`
    /// read and the count is the unread records in the one or two files those
    /// candidates name — never a scan of the issue tree (design D7).
    pub fn refresh_mentions(&mut self) {
        self.mention_candidates = crate::mentions::identity::local_candidates();
        self.unread_mentions = match self.mention_store() {
            Some(store) => store.unread_count(&self.mention_candidates),
            None => 0,
        };
    }

    /// Open the all-issues mentions view, or say there is nothing to show.
    pub(in crate::app) fn open_mentions(&mut self) {
        // A disabled feature says so rather than reporting an empty queue: "no pending
        // mentions" for `enabled = false` would tell the user their mentions were handled
        // when the feature is simply not running, which is the same distinction the CLI's
        // `tix mentions` refusal draws.
        let Some(store) = self.mention_store() else {
            self.status_msg =
                Some("mentions are disabled (`[mentions] enabled = false`)".to_string());
            return;
        };
        let rows = store.pending_for(&self.mention_candidates);
        if rows.is_empty() {
            self.status_msg = Some("no pending mentions".to_string());
            return;
        }
        self.mentions_view = Some(crate::ui::mentions::MentionsView::new(rows, None));
        self.mode = Mode::Mentions;
    }

    /// Show the notice when `number` has unread mentions for the local user.
    pub(in crate::app) fn notice_mentions_for(&mut self, number: u32) {
        let rows = self
            .mention_store()
            .map(|store| store.pending_for_issue(&self.mention_candidates, number))
            .unwrap_or_default();
        if rows.is_empty() {
            return;
        }
        self.mentions = Some(crate::ui::mentions::MentionsView::new(rows, Some(number)));
        self.mode = Mode::MentionNotice;
    }

    /// Mark one mention read and drop it from the surface that showed it.
    ///
    /// The one **mutating** answer of the three; the other two change nothing, which
    /// is the property that makes "ignore" mean "show me this again".
    pub(in crate::app) fn mark_mention_read(&mut self, id: &str) {
        if let Some(store) = self.mention_store() {
            if let Err(e) = store.mark_read(&self.mention_candidates, id) {
                self.status_msg = Some(format!("mentions: {e}"));
            }
        }
        self.refresh_mentions();
    }

    /// Scroll the detail pane so the mention with `key` is in view.
    ///
    /// The location is recomputed from the stored issue, not remembered: the
    /// fingerprint is the identity, so a line number recorded at reconciliation time
    /// would be stale the moment anything above the mention was edited.
    pub(in crate::app) fn jump_to_mention(&mut self, key: &str) {
        let Some(detail) = self.detail.as_ref() else {
            return;
        };
        let issue = detail.issue.clone();
        let Some(line) = Self::mention_line_of(&issue, key) else {
            return;
        };
        if let Some(detail) = self.detail.as_mut() {
            detail.focus_body_line(line);
        }
    }

    /// The body line the mention with `key` sits on, if it is still there.
    pub(in crate::app) fn mention_line_of(issue: &Issue, key: &str) -> Option<usize> {
        let id = issue.id.clone().unwrap_or_else(|| issue.filename.clone());
        let located = crate::mentions::locate(
            crate::mentions::IssueContent {
                id: &id,
                number: issue.number,
                file: &issue.filename,
                title: &issue.title,
                body: &issue.body,
            },
            key,
        )?;
        located.line
    }

    /// The raw keys of the per-issue notice.
    ///
    /// Raw rather than bound, so `j`/`k` stay navigation and `i` means "ignore" here
    /// whatever the board binds it to. `Esc` (and `i`) close without writing; only
    /// `r` reaches the store.
    pub(in crate::app) fn handle_mention_notice_key(&mut self, key: KeyEvent) -> Result<Action> {
        if self.mentions.is_none() {
            self.mode = Mode::Detail;
            return Ok(Action::None);
        }
        match key.code {
            KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('i') => {
                self.mentions = None;
                self.mode = Mode::Detail;
            }
            KeyCode::Down | KeyCode::Char('j') => {
                if let Some(view) = self.mentions.as_mut() {
                    view.move_by(1);
                }
            }
            KeyCode::Up | KeyCode::Char('k') => {
                if let Some(view) = self.mentions.as_mut() {
                    view.move_by(-1);
                }
            }
            KeyCode::Enter => {
                let key = self
                    .mentions
                    .as_ref()
                    .and_then(|view| view.current())
                    .map(|mention| mention.key.clone());
                self.mentions = None;
                self.mode = Mode::Detail;
                if let Some(key) = key {
                    self.jump_to_mention(&key);
                }
            }
            KeyCode::Char('r') => {
                let id = self
                    .mentions
                    .as_ref()
                    .and_then(|view| view.current())
                    .map(|mention| mention.id.clone());
                if let Some(id) = id {
                    self.mark_mention_read(&id);
                    if let Some(view) = self.mentions.as_mut() {
                        view.drop_current();
                    }
                    if self
                        .mentions
                        .as_ref()
                        .is_some_and(crate::ui::mentions::MentionsView::is_empty)
                    {
                        self.mentions = None;
                        self.mode = Mode::Detail;
                    }
                }
            }
            _ => {}
        }
        Ok(Action::None)
    }

    /// The raw keys of the all-issues mentions view.
    ///
    /// `i` closes the view without writing — the same "ignore for now" the notice
    /// offers, spelled the way the `tix mentions ignore` surface spells it.
    pub(in crate::app) fn handle_mentions_view_key(&mut self, key: KeyEvent) -> Result<Action> {
        if self.mentions_view.is_none() {
            self.mode = Mode::Board;
            return Ok(Action::None);
        }
        match key.code {
            KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('i') => {
                self.mentions_view = None;
                self.mode = Mode::Board;
            }
            KeyCode::Down | KeyCode::Char('j') => {
                if let Some(view) = self.mentions_view.as_mut() {
                    view.move_by(1);
                }
            }
            KeyCode::Up | KeyCode::Char('k') => {
                if let Some(view) = self.mentions_view.as_mut() {
                    view.move_by(-1);
                }
            }
            KeyCode::Enter => {
                let selected = self
                    .mentions_view
                    .as_ref()
                    .and_then(|view| view.current())
                    .cloned();
                let Some(mention) = selected else {
                    return Ok(Action::None);
                };
                self.mentions_view = None;
                self.open_issue_by_number(mention.issue_number)?;
                // The row the user chose is the one they meant to see, so the issue's
                // own notice does not preempt the jump it was opened for — and the mode
                // it may have entered is the pane the jump acts on.
                self.mentions = None;
                self.mode = Mode::Detail;
                self.jump_to_mention(&mention.key);
            }
            KeyCode::Char('r') => {
                let id = self
                    .mentions_view
                    .as_ref()
                    .and_then(|view| view.current())
                    .map(|mention| mention.id.clone());
                if let Some(id) = id {
                    self.mark_mention_read(&id);
                    if let Some(view) = self.mentions_view.as_mut() {
                        view.drop_current();
                    }
                    if self
                        .mentions_view
                        .as_ref()
                        .is_some_and(crate::ui::mentions::MentionsView::is_empty)
                    {
                        self.mentions_view = None;
                        self.mode = Mode::Board;
                    }
                }
            }
            _ => {}
        }
        Ok(Action::None)
    }
}