Skip to main content

tix/app/
mentions.rs

1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4impl App {
5    /// The mention store for this repository, or `None` when `[mentions] enabled =
6    /// false`.
7    ///
8    /// Delegated rather than re-derived: the store owns one constructor, so the TUI,
9    /// the CLI and the write seam cannot disagree about which directory the feature
10    /// reads and writes.
11    pub(in crate::app) fn mention_store(&self) -> Option<crate::mentions::store::MentionStore> {
12        self.store.mention_store()
13    }
14
15    /// Recompute the local user's mention candidates and unread count.
16    ///
17    /// Read-only, and cheap by construction: the candidates are one `git config`
18    /// read and the count is the unread records in the one or two files those
19    /// candidates name — never a scan of the issue tree (design D7).
20    pub fn refresh_mentions(&mut self) {
21        self.mention_candidates = crate::mentions::identity::local_candidates();
22        self.unread_mentions = match self.mention_store() {
23            Some(store) => store.unread_count(&self.mention_candidates),
24            None => 0,
25        };
26    }
27
28    /// React to the `docs/mentions` directory changing under a live session (the
29    /// watcher polls it beside the issues tree): re-read the local user's count from
30    /// the store and, when it **grew**, put the modal up.
31    ///
32    /// The candidates (git identity) are not re-derived here: a file change does not
33    /// change who the local user is, and the watcher's job is the count, not a fresh
34    /// `git config` read. "Grew" rather than "changed" is what keeps the session's
35    /// own writes from re-opening the view: marking a mention read shrinks the count,
36    /// and the watcher reports that file write on the next tick, but nothing new
37    /// arrived to show. Like the recurring warning, it only raises from the board —
38    /// a form or another modal is not interrupted — and it folds new rows into an
39    /// already-open view instead of leaving that view stale.
40    pub(in crate::app) fn refresh_mentions_after_change(&mut self) {
41        let before = self.unread_mentions;
42        self.unread_mentions = match self.mention_store() {
43            Some(store) => store.unread_count(&self.mention_candidates),
44            None => 0,
45        };
46        if self.unread_mentions <= before {
47            return;
48        }
49        if matches!(self.mode, Mode::Mentions) {
50            // The view is already open: re-read the rows and keep the cursor.
51            let Some(store) = self.mention_store() else {
52                return;
53            };
54            let rows = store.pending_for(&self.mention_candidates);
55            let selected = self
56                .mentions_view
57                .as_ref()
58                .map(|view| view.selected)
59                .unwrap_or(0);
60            self.mentions_view = Some(crate::ui::mentions::MentionsView::new(rows, None));
61            if let Some(view) = self.mentions_view.as_mut() {
62                view.selected = selected.min(view.rows.len().saturating_sub(1));
63            }
64        } else if matches!(self.mode, Mode::Board) {
65            self.open_mentions();
66        }
67    }
68
69    /// Open the all-issues mentions view, or say there is nothing to show.
70    pub(in crate::app) fn open_mentions(&mut self) {
71        // A disabled feature says so rather than reporting an empty queue: "no pending
72        // mentions" for `enabled = false` would tell the user their mentions were handled
73        // when the feature is simply not running, which is the same distinction the CLI's
74        // `tix mentions` refusal draws.
75        let Some(store) = self.mention_store() else {
76            self.status_msg =
77                Some("mentions are disabled (`[mentions] enabled = false`)".to_string());
78            return;
79        };
80        let rows = store.pending_for(&self.mention_candidates);
81        if rows.is_empty() {
82            self.status_msg = Some("no pending mentions".to_string());
83            return;
84        }
85        self.mentions_view = Some(crate::ui::mentions::MentionsView::new(rows, None));
86        self.mode = Mode::Mentions;
87    }
88
89    /// Show the notice when `number` has unread mentions for the local user.
90    pub(in crate::app) fn notice_mentions_for(&mut self, number: u32) {
91        let rows = self
92            .mention_store()
93            .map(|store| store.pending_for_issue(&self.mention_candidates, number))
94            .unwrap_or_default();
95        if rows.is_empty() {
96            return;
97        }
98        self.mentions = Some(crate::ui::mentions::MentionsView::new(rows, Some(number)));
99        self.mode = Mode::MentionNotice;
100    }
101
102    /// Mark one mention read and drop it from the surface that showed it.
103    ///
104    /// The one **mutating** answer of the three; the other two change nothing, which
105    /// is the property that makes "ignore" mean "show me this again".
106    pub(in crate::app) fn mark_mention_read(&mut self, id: &str) {
107        if let Some(store) = self.mention_store() {
108            if let Err(e) = store.mark_read(&self.mention_candidates, id) {
109                self.status_msg = Some(format!("mentions: {e}"));
110            }
111        }
112        self.refresh_mentions();
113    }
114
115    /// Scroll the detail pane so the mention with `key` is in view.
116    ///
117    /// The location is recomputed from the stored issue, not remembered: the
118    /// fingerprint is the identity, so a line number recorded at reconciliation time
119    /// would be stale the moment anything above the mention was edited.
120    pub(in crate::app) fn jump_to_mention(&mut self, key: &str) {
121        let Some(detail) = self.detail.as_ref() else {
122            return;
123        };
124        let issue = detail.issue.clone();
125        let Some(body) = Self::mention_body_of(&issue, key) else {
126            return;
127        };
128        // The render pass does the scroll and the highlight: it alone knows the body's
129        // content-row offset and the viewport height, so the jump records the target
130        // and lets the next draw put it at the top.
131        if let Some(detail) = self.detail.as_mut() {
132            detail.mention_body = Some(body);
133            detail.focus_pending.set(true);
134        }
135    }
136
137    /// The mention body token (`alice`, not `@alice`) the record with `key` carries,
138    /// if the mention is still in the issue.
139    pub(in crate::app) fn mention_body_of(issue: &Issue, key: &str) -> Option<String> {
140        let id = issue.id.clone().unwrap_or_else(|| issue.filename.clone());
141        let located = crate::mentions::locate(
142            crate::mentions::IssueContent {
143                id: &id,
144                number: issue.number,
145                file: &issue.filename,
146                title: &issue.title,
147                body: &issue.body,
148            },
149            key,
150        )?;
151        Some(located.body)
152    }
153
154    /// The raw keys of the per-issue notice.
155    ///
156    /// Raw rather than bound, so `j`/`k` stay navigation and `i` means "ignore" here
157    /// whatever the board binds it to. `Esc` (and `i`) close without writing; only
158    /// `r` reaches the store.
159    pub(in crate::app) fn handle_mention_notice_key(&mut self, key: KeyEvent) -> Result<Action> {
160        if self.mentions.is_none() {
161            self.mode = Mode::Detail;
162            return Ok(Action::None);
163        }
164        match key.code {
165            KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('i') => {
166                self.mentions = None;
167                self.mode = Mode::Detail;
168            }
169            KeyCode::Down | KeyCode::Char('j') => {
170                if let Some(view) = self.mentions.as_mut() {
171                    view.move_by(1);
172                }
173            }
174            KeyCode::Up | KeyCode::Char('k') => {
175                if let Some(view) = self.mentions.as_mut() {
176                    view.move_by(-1);
177                }
178            }
179            KeyCode::Enter => {
180                let key = self
181                    .mentions
182                    .as_ref()
183                    .and_then(|view| view.current())
184                    .map(|mention| mention.key.clone());
185                self.mentions = None;
186                self.mode = Mode::Detail;
187                if let Some(key) = key {
188                    self.jump_to_mention(&key);
189                }
190            }
191            KeyCode::Char('r') => {
192                let id = self
193                    .mentions
194                    .as_ref()
195                    .and_then(|view| view.current())
196                    .map(|mention| mention.id.clone());
197                if let Some(id) = id {
198                    self.mark_mention_read(&id);
199                    if let Some(view) = self.mentions.as_mut() {
200                        view.drop_current();
201                    }
202                    if self
203                        .mentions
204                        .as_ref()
205                        .is_some_and(crate::ui::mentions::MentionsView::is_empty)
206                    {
207                        self.mentions = None;
208                        self.mode = Mode::Detail;
209                    }
210                }
211            }
212            _ => {}
213        }
214        Ok(Action::None)
215    }
216
217    /// The raw keys of the all-issues mentions view.
218    ///
219    /// `i` closes the view without writing — the same "ignore for now" the notice
220    /// offers, spelled the way the `tix mentions ignore` surface spells it.
221    pub(in crate::app) fn handle_mentions_view_key(&mut self, key: KeyEvent) -> Result<Action> {
222        if self.mentions_view.is_none() {
223            self.mode = Mode::Board;
224            return Ok(Action::None);
225        }
226        match key.code {
227            KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('i') => {
228                self.mentions_view = None;
229                self.mode = Mode::Board;
230            }
231            KeyCode::Down | KeyCode::Char('j') => {
232                if let Some(view) = self.mentions_view.as_mut() {
233                    view.move_by(1);
234                }
235            }
236            KeyCode::Up | KeyCode::Char('k') => {
237                if let Some(view) = self.mentions_view.as_mut() {
238                    view.move_by(-1);
239                }
240            }
241            KeyCode::Enter => {
242                let selected = self
243                    .mentions_view
244                    .as_ref()
245                    .and_then(|view| view.current())
246                    .cloned();
247                let Some(mention) = selected else {
248                    return Ok(Action::None);
249                };
250                self.mentions_view = None;
251                self.open_issue_by_number(mention.issue_number)?;
252                // The row the user chose is the one they meant to see, so the issue's
253                // own notice does not preempt the jump it was opened for — and the mode
254                // it may have entered is the pane the jump acts on.
255                self.mentions = None;
256                self.mode = Mode::Detail;
257                self.jump_to_mention(&mention.key);
258            }
259            KeyCode::Char('r') => {
260                let id = self
261                    .mentions_view
262                    .as_ref()
263                    .and_then(|view| view.current())
264                    .map(|mention| mention.id.clone());
265                if let Some(id) = id {
266                    self.mark_mention_read(&id);
267                    if let Some(view) = self.mentions_view.as_mut() {
268                        view.drop_current();
269                    }
270                    if self
271                        .mentions_view
272                        .as_ref()
273                        .is_some_and(crate::ui::mentions::MentionsView::is_empty)
274                    {
275                        self.mentions_view = None;
276                        self.mode = Mode::Board;
277                    }
278                }
279            }
280            _ => {}
281        }
282        Ok(Action::None)
283    }
284}