tix-tracker 0.12.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,
        };
    }

    /// React to the `docs/mentions` directory changing under a live session (the
    /// watcher polls it beside the issues tree): re-read the local user's count from
    /// the store and, when it **grew**, put the modal up.
    ///
    /// The candidates (git identity) are not re-derived here: a file change does not
    /// change who the local user is, and the watcher's job is the count, not a fresh
    /// `git config` read. "Grew" rather than "changed" is what keeps the session's
    /// own writes from re-opening the view: marking a mention read shrinks the count,
    /// and the watcher reports that file write on the next tick, but nothing new
    /// arrived to show. Like the recurring warning, it only raises from the board —
    /// a form or another modal is not interrupted — and it folds new rows into an
    /// already-open view instead of leaving that view stale.
    pub(in crate::app) fn refresh_mentions_after_change(&mut self) {
        let before = self.unread_mentions;
        self.unread_mentions = match self.mention_store() {
            Some(store) => store.unread_count(&self.mention_candidates),
            None => 0,
        };
        if self.unread_mentions <= before {
            return;
        }
        if matches!(self.mode, Mode::Mentions) {
            // The view is already open: re-read the rows and keep the cursor.
            let Some(store) = self.mention_store() else {
                return;
            };
            let rows = store.pending_for(&self.mention_candidates);
            let selected = self
                .mentions_view
                .as_ref()
                .map(|view| view.selected)
                .unwrap_or(0);
            self.mentions_view = Some(crate::ui::mentions::MentionsView::new(rows, None));
            if let Some(view) = self.mentions_view.as_mut() {
                view.selected = selected.min(view.rows.len().saturating_sub(1));
            }
        } else if matches!(self.mode, Mode::Board) {
            self.open_mentions();
        }
    }

    /// 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(body) = Self::mention_body_of(&issue, key) else {
            return;
        };
        // The render pass does the scroll and the highlight: it alone knows the body's
        // content-row offset and the viewport height, so the jump records the target
        // and lets the next draw put it at the top.
        if let Some(detail) = self.detail.as_mut() {
            detail.mention_body = Some(body);
            detail.focus_pending.set(true);
        }
    }

    /// The mention body token (`alice`, not `@alice`) the record with `key` carries,
    /// if the mention is still in the issue.
    pub(in crate::app) fn mention_body_of(issue: &Issue, key: &str) -> Option<String> {
        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,
        )?;
        Some(located.body)
    }

    /// 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)
    }
}