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}