Skip to main content

tix/app/
detail_input.rs

1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4impl App {
5    /// Open the detail pane on the issue a shared issue-table row names
6    /// ([`Action::OpenIssue`]).
7    ///
8    /// The row's filename is store-relative, exactly what
9    /// [`IssueSelector::Filename`] takes; a file that has vanished since the
10    /// draw surfaces as an `Err` the caller reports through the status line
11    /// rather than opening an empty detail.
12    pub fn open_issue_by_filename(&mut self, filename: &str) -> Result<()> {
13        let issue = self
14            .store
15            .get(IssueSelector::Filename(filename.to_string()))?;
16        self.open_detail_root(issue)
17    }
18
19    /// Open `issue`'s detail pane as a **fresh root**: the back stack is cleared,
20    /// because the navigation began at the board rather than from another pane
21    /// (tix #1513).
22    pub(in crate::app) fn open_detail_root(&mut self, issue: Issue) -> Result<()> {
23        // The pane is about to describe one issue's health, so the document it reads
24        // from is re-read here: a value another process recorded a moment ago is what
25        // the reader expects to see, not what was on disk when the board was built.
26        self.refresh_health();
27        self.nav_stack.clear();
28        self.related_picker = None;
29        let number = issue.number;
30        self.detail = Some(self.detail_state_for(issue)?);
31        self.mode = Mode::Detail;
32        // Design D7: opening an issue is the moment a pending mention is surfaced.
33        // The pane is already the detail view, so dismissing the notice returns to
34        // it and the mention it pointed at is behind it.
35        self.notice_mentions_for(number);
36        Ok(())
37    }
38
39    /// Open the issue numbered `number` in the detail pane, if it still exists.
40    pub(in crate::app) fn open_issue_by_number(&mut self, number: u32) -> Result<()> {
41        let issue = self.store.get(IssueSelector::Number(number))?;
42        self.open_detail_root(issue)
43    }
44
45    /// Build a detail pane for a **store** issue, service-resolved.
46    ///
47    /// One builder for every path that opens a store issue (the board's Enter, a
48    /// double-click, and following a local related entry), so the `related` list can
49    /// never be populated on one path and forgotten on another. The pane is
50    /// writable: a store issue has a file to route writes through.
51    pub(in crate::app) fn detail_state_for(&self, issue: Issue) -> Result<DetailState> {
52        // Service-resolved, so the pane shows the relationships that touch this
53        // issue rather than the ones it happens to own.
54        let relationships = self.store.relationships_for(issue.number)?;
55        // One graph for the chain and the blocked note, so the two rows cannot
56        // describe different snapshots of the same issue.
57        let (all, done_column) = self.store.graph_source()?;
58        let graph = commands::graph_over(&all, &done_column);
59        let number = issue.number;
60        // WP05: the resolved category, for the detail row (T023).
61        let category = self.category_of_issue(&issue);
62        // #1513: classified once, here, so the renderer stays a pure function of
63        // the pane's state.
64        let related = crate::related::classify_all(&issue.related_issues);
65        let wip = self.wip_line(&issue);
66        // The scope's health, from the same document the CLI renders. `None` when the
67        // document has no entry for the issue, which the pane renders as no block at
68        // all rather than as an empty one.
69        let scope = crate::health::HealthScope::of_issue(issue.number);
70        let health = self
71            .health
72            .as_ref()
73            .and_then(|document| document.scope(&scope).cloned());
74        let health_diagnostics = self
75            .health
76            .as_ref()
77            .map(|document| {
78                document
79                    .diagnostics_for(&scope)
80                    .into_iter()
81                    .map(|diagnostic| diagnostic.to_diagnostic())
82                    .collect()
83            })
84            .unwrap_or_default();
85        // WP04 T019: the issue's referenced epics, from the one service read the CLI
86        // epic block uses, so the pane and `tix show` cannot disagree about an epic
87        // (C-008). The snapshot already read above is reused rather than re-walked.
88        // Empty for an issue in no epic, which the pane draws no heading for.
89        let epics =
90            commands::view::epic_entries_over(&self.store.config, &all, &done_column, &issue)?;
91        // WP06 T031: the capacity scopes this issue's own references name, from the
92        // shared calculation read at the last refresh — never a per-open disk read.
93        let capacity = self.issue_capacity(&issue);
94        let mut state = DetailState {
95            health,
96            health_diagnostics,
97            issue,
98            wip,
99            category,
100            relationships,
101            chain: graph.dependency_chain(number),
102            blocked: commands::blocked_note(&graph.blocked_state(number)),
103            // From the **same** `all` snapshot the graph was built over, so the
104            // hierarchy rows and the relationship rows cannot describe different
105            // states of the same issue.
106            hierarchy: commands::parent::facts_over(&all, &done_column, number),
107            scroll_offset: std::cell::Cell::new(0),
108            body_start: std::cell::Cell::new(0),
109            mention_body: None,
110            focus_pending: std::cell::Cell::new(false),
111            viewport: std::cell::Cell::new(0),
112            max_scroll: std::cell::Cell::new(0),
113            rel_cursor: 0,
114            related,
115            related_cursor: 0,
116            read_only: false,
117            epics,
118            capacity,
119        };
120        state.clamp_related_cursor();
121        Ok(state)
122    }
123
124    /// Open an issue parsed from a file **outside** the store in the pane,
125    /// read-only (tix #1513).
126    ///
127    /// There is no store entry to resolve relationships or a dependency graph
128    /// against, so the pane carries the issue and its `related` list and nothing
129    /// derived from the graph. `read_only` makes `e`/`c`/`a` and the relationship
130    /// operations refuse rather than fail with a lookup error.
131    pub(in crate::app) fn open_external_detail(&mut self, issue: Issue) {
132        let number = issue.number;
133        let related = crate::related::classify_all(&issue.related_issues);
134        // No store snapshot to compute hierarchy over beyond the issue itself.
135        let hierarchy = commands::parent::facts_over(std::slice::from_ref(&issue), "done", number);
136        let wip = self.wip_line(&issue);
137        let mut state = DetailState {
138            health: None,
139            health_diagnostics: Vec::new(),
140            issue,
141            wip,
142            category: None,
143            relationships: Vec::new(),
144            chain: crate::relationships::DependencyChain::default(),
145            blocked: None,
146            hierarchy,
147            scroll_offset: std::cell::Cell::new(0),
148            body_start: std::cell::Cell::new(0),
149            mention_body: None,
150            focus_pending: std::cell::Cell::new(false),
151            viewport: std::cell::Cell::new(0),
152            max_scroll: std::cell::Cell::new(0),
153            rel_cursor: 0,
154            related,
155            related_cursor: 0,
156            read_only: true,
157            // An external file has no store, so no epic service read is available; the
158            // pane shows no epic block rather than a stale one.
159            epics: Vec::new(),
160            // And no capacity read either: the pane is about a file, not a store
161            // issue, so it shows no capacity block rather than a stale one.
162            capacity: Vec::new(),
163        };
164        state.clamp_related_cursor();
165        self.detail = Some(state);
166        self.mode = Mode::Detail;
167    }
168
169    #[allow(clippy::too_many_lines)]
170    pub(in crate::app) fn handle_detail_key(&mut self, key: KeyEvent) -> Result<Action> {
171        // The related picker overlays the pane (#1513), exactly as `form_picker`
172        // overlays the form: while it is open it owns the keyboard.
173        if self.related_picker.is_some() {
174            return self.handle_related_picker_key(key);
175        }
176        // An external, read-only issue has no store to route a write through, so
177        // the mutating keys refuse up front rather than failing with a lookup
178        // error from the store (#1513).
179        if self.detail.as_ref().is_some_and(|detail| detail.read_only)
180            && matches!(key.code, KeyCode::Char('e' | 'c' | 'a' | 'n' | 'E' | 'd'))
181        {
182            self.status_msg = Some("read-only: the issue is outside the store".to_string());
183            return Ok(Action::None);
184        }
185        // Mission `project-issue-health-status-01M3C8HX` (WP06): the health form, on
186        // the issue the pane is showing. Handled here, in the raw-key style the rest of
187        // this handler follows; the `[detail]` entry exists so the key is
188        // listed in the help overlay like every other.
189        if key.code == KeyCode::Char('H') {
190            return self.open_health_form();
191        }
192        match key.code {
193            KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => {
194                self.mode = Mode::Board;
195                self.detail = None;
196                // The navigation that ended here is over with it; a back stack left
197                // behind would let a later pane `Backspace` into a screen the reader
198                // had already closed (#1513).
199                self.nav_stack.clear();
200                self.related_picker = None;
201                Ok(Action::None)
202            }
203            KeyCode::Char('e') => {
204                if let Some(ref d) = self.detail {
205                    let filename = d.issue.filename.clone();
206                    Ok(Action::OpenEditor(filename))
207                } else {
208                    Ok(Action::None)
209                }
210            }
211            KeyCode::Char('v') => {
212                if let Some(d) = &self.detail {
213                    Ok(Action::OpenViewer(d.issue.filename.clone()))
214                } else {
215                    Ok(Action::None)
216                }
217            }
218            KeyCode::PageUp => {
219                if let Some(d) = self.detail.as_mut() {
220                    let page = d.page();
221                    d.scroll_by(-i32::from(page));
222                }
223                Ok(Action::None)
224            }
225            KeyCode::PageDown => {
226                if let Some(d) = self.detail.as_mut() {
227                    let page = d.page();
228                    d.scroll_by(i32::from(page));
229                }
230                Ok(Action::None)
231            }
232            KeyCode::Char('?') => {
233                self.open_help(Mode::Detail);
234                Ok(Action::None)
235            }
236            KeyCode::Char('c') => {
237                self.mode = Mode::Comment {
238                    buffer: String::new(),
239                };
240                Ok(Action::None)
241            }
242            KeyCode::Char('a') => {
243                self.mode = Mode::Asset {
244                    buffer: String::new(),
245                };
246                Ok(Action::None)
247            }
248            // #1359: the three mutating relationship operations, plus the cursor
249            // naming which edge `E` and `d` act on.
250            KeyCode::Char('n') => {
251                self.mode = Mode::LinkCreate {
252                    buffer: String::new(),
253                    step: LinkStep::Target,
254                    target: None,
255                };
256                Ok(Action::None)
257            }
258            // #1384: set, change or clear the open issue's parent. `P` was free in
259            // the detail pane; the write goes through the shared hierarchy service,
260            // so the TUI gains the `tix edit --parent` parity the create-only form
261            // could not express.
262            KeyCode::Char('P') => {
263                self.mode = Mode::ParentPrompt {
264                    buffer: String::new(),
265                };
266                Ok(Action::None)
267            }
268            KeyCode::Char('E') => {
269                // Only meaningful with an edge under the cursor; with none selected
270                // the key says so rather than doing nothing, because a silently
271                // ignored key is indistinguishable from a missing binding.
272                match self.selected_relationship_id() {
273                    Some(id) => {
274                        let index = self.detail.as_ref().map(|d| d.rel_cursor).unwrap_or(0);
275                        self.mode = Mode::LinkEdit {
276                            index,
277                            buffer: String::new(),
278                            step: LinkStep::Target,
279                            target: None,
280                            kind: None,
281                        };
282                        self.status_msg = Some(format!("Re-kind {id}"));
283                    }
284                    None => {
285                        self.status_msg = Some(
286                            "no relationship selected — press Tab or Shift+Tab first".to_string(),
287                        );
288                    }
289                }
290                Ok(Action::None)
291            }
292            KeyCode::Char('d') => {
293                match self.selected_relationship().cloned() {
294                    Some(rel) => {
295                        let line = crate::commands::link::relationship_line(&rel);
296                        let index = self.detail.as_ref().map(|d| d.rel_cursor).unwrap_or(0);
297                        self.status_msg = Some(format!("Remove {line}? (y to confirm)"));
298                        self.mode = Mode::ConfirmRemoveRelationship { index };
299                    }
300                    None => {
301                        self.status_msg = Some(
302                            "no relationship selected — press Tab or Shift+Tab first".to_string(),
303                        );
304                    }
305                }
306                Ok(Action::None)
307            }
308            // `j`/`k` (and the arrows) scroll the **body**, one row per press; `g`/`G`
309            // jump to the top and bottom. These arrive as their canonical events
310            // (`Down`/`Up`/`g`/`G`) whatever the user rebound them to. The
311            // relationship cursor moved to `Tab`/`BackTab`, so scrolling no longer
312            // collides with selecting an edge.
313            KeyCode::Down => {
314                if let Some(d) = self.detail.as_mut() {
315                    d.scroll_by(1);
316                }
317                Ok(Action::None)
318            }
319            KeyCode::Up => {
320                if let Some(d) = self.detail.as_mut() {
321                    d.scroll_by(-1);
322                }
323                Ok(Action::None)
324            }
325            KeyCode::Char('g') => {
326                if let Some(d) = self.detail.as_mut() {
327                    d.scroll_by(-i32::from(d.scroll_offset.get()));
328                }
329                Ok(Action::None)
330            }
331            KeyCode::Char('G') => {
332                if let Some(d) = self.detail.as_mut() {
333                    d.scroll_by(i32::from(d.max_scroll.get()));
334                }
335                Ok(Action::None)
336            }
337            KeyCode::Tab => {
338                self.move_rel_cursor(1);
339                Ok(Action::None)
340            }
341            KeyCode::BackTab => {
342                self.move_rel_cursor(-1);
343                Ok(Action::None)
344            }
345            // #1513: the related-issues surface. `r` lists them, `o` follows the
346            // selected one, `Backspace` returns to the previous pane. `j`/`k` are
347            // deliberately **not** reused: they move the relationship cursor, and
348            // the picker is the selection surface for related entries.
349            KeyCode::Char('r') => {
350                self.open_related_picker();
351                Ok(Action::None)
352            }
353            KeyCode::Char('o') => self.follow_related(),
354            KeyCode::Backspace => {
355                self.nav_back();
356                Ok(Action::None)
357            }
358            _ => Ok(Action::None),
359        }
360    }
361
362    /// Open the related-issues picker over the detail pane (tix #1513).
363    ///
364    /// A no-op with nothing to pick, so `r` on an issue with no `related_issues`
365    /// does not open an empty box.
366    pub(in crate::app) fn open_related_picker(&mut self) {
367        let Some(detail) = self.detail.as_ref() else {
368            return;
369        };
370        if detail.related.is_empty() {
371            self.status_msg = Some("no related issues".to_string());
372            return;
373        }
374        let options: Vec<PickerItem> = detail
375            .related
376            .iter()
377            .enumerate()
378            .map(|(index, entry)| PickerItem {
379                id: index.to_string(),
380                label: entry.display(),
381            })
382            .collect();
383        let mut state = DropdownState::single(options);
384        // Start on the entry `o` would follow, so the picker opens where the
385        // reader already is.
386        state.cursor = detail
387            .related_cursor
388            .min(state.visible_indices().len().saturating_sub(1));
389        self.related_picker = Some(state);
390    }
391
392    /// Keyboard routing for the open related picker (tix #1513): the picker owns
393    /// the keys while it is open, exactly as `handle_form_picker_key` does for a
394    /// form dropdown. Canonical events arrive (`Up`/`Down`/`Enter`/`Esc`) because
395    /// `InputContext::RelatedPicker` binds `next`/`previous`/`submit`/`cancel`.
396    pub(in crate::app) fn handle_related_picker_key(&mut self, key: KeyEvent) -> Result<Action> {
397        let event = match self.related_picker.as_mut() {
398            Some(picker) => picker.handle_key(key.code),
399            None => return Ok(Action::None),
400        };
401        match event {
402            PickerEvent::None => {}
403            PickerEvent::Cancelled => {
404                self.related_picker = None;
405            }
406            PickerEvent::Selected(ids) => {
407                self.related_picker = None;
408                if let Some(id) = ids.first().and_then(|id| id.parse::<usize>().ok()) {
409                    if let Some(detail) = self.detail.as_mut() {
410                        detail.related_cursor = id;
411                        detail.clamp_related_cursor();
412                    }
413                    // Selecting an entry means "go there", so the picker is a
414                    // navigation surface rather than a second selection step.
415                    return self.follow_related();
416                }
417            }
418            // The picker has no inline create; a `Created` event cannot arise from
419            // the keys bound in its context, and ignoring it is honest.
420            PickerEvent::Created(_) => {}
421        }
422        Ok(Action::None)
423    }
424
425    /// Follow the detail pane's selected related entry (tix #1513).
426    ///
427    /// Three shapes, matching [`crate::related::RelatedRef`]:
428    /// - `Local` resolves through the store and opens the issue in the pane;
429    /// - `Path` opens the parsed file read-only (it may be outside the store), or
430    ///   hands the path to the resolved viewer when it does not parse as an issue;
431    /// - `Url` launches the system browser, and falls back to showing the URL so it
432    ///   can be copied.
433    ///
434    /// Every failure is a status message, never an `Err`: a broken link in one issue
435    /// must not end the session.
436    pub(in crate::app) fn follow_related(&mut self) -> Result<Action> {
437        let Some(detail) = self.detail.as_ref() else {
438            return Ok(Action::None);
439        };
440        let Some(entry) = detail.related.get(detail.related_cursor).cloned() else {
441            // Nothing to follow, and saying so is better than a key that silently
442            // does nothing (#1513).
443            self.status_msg = Some("no related issues".to_string());
444            return Ok(Action::None);
445        };
446        let base = crate::related::base_dir(&self.store.issues_dir, &detail.issue.filename);
447        match entry {
448            crate::related::RelatedRef::Local(raw) => {
449                match crate::related::resolve_local(&self.store, &raw) {
450                    // Built **before** the snapshot is pushed, so a store failure
451                    // leaves the stack and the pane untouched and reports instead of
452                    // ending the session — the same rule the path and URL branches
453                    // follow.
454                    Ok(issue) => match self.detail_state_for(issue) {
455                        Ok(state) => {
456                            self.push_detail_snapshot();
457                            self.detail = Some(state);
458                            self.mode = Mode::Detail;
459                        }
460                        Err(e) => self.status_msg = Some(format!("related {raw}: {e}")),
461                    },
462                    Err(e) => self.status_msg = Some(format!("related {raw}: {e}")),
463                }
464            }
465            crate::related::RelatedRef::Path { .. } => return self.follow_path(&base, &entry),
466            crate::related::RelatedRef::Url { .. } => {
467                let url = entry.display();
468                match open_in_browser(&url) {
469                    Ok(()) => self.status_msg = Some(format!("opened {url}")),
470                    // Not an error the app can act on: the URL is shown so it can
471                    // be copied into a browser by hand.
472                    Err(e) => self.status_msg = Some(format!("{url} (could not open: {e})")),
473                }
474            }
475        }
476        Ok(Action::None)
477    }
478
479    /// Follow a `Path` entry (tix #1513).
480    ///
481    /// A path that lands **inside** the store is opened as an ordinary issue
482    /// through the service (it is not "external" just because it was written as a
483    /// path); anything else is opened read-only when it parses as an issue, or
484    /// handed to the read-only viewer when it does not — the viewer needs the
485    /// terminal, so it travels back as an [`Action`].
486    pub(in crate::app) fn follow_path(
487        &mut self,
488        base: &Path,
489        entry: &crate::related::RelatedRef,
490    ) -> Result<Action> {
491        let Some(path) = crate::related::resolve_path(base, entry) else {
492            return Ok(Action::None);
493        };
494        if let Ok(relative) = path.strip_prefix(&self.store.issues_dir) {
495            let name = relative.to_string_lossy().to_string();
496            if let Ok(issue) = self.store.get(IssueSelector::Filename(name)) {
497                match self.detail_state_for(issue) {
498                    Ok(state) => {
499                        self.push_detail_snapshot();
500                        self.detail = Some(state);
501                        self.mode = Mode::Detail;
502                    }
503                    // A store failure is reported, not propagated: a link must not be
504                    // able to end the session (#1513).
505                    Err(e) => self.status_msg = Some(format!("related: {e}")),
506                }
507                return Ok(Action::None);
508            }
509        }
510        match crate::related::read_issue_file(&path) {
511            Ok(issue) => {
512                self.push_detail_snapshot();
513                self.open_external_detail(issue);
514                Ok(Action::None)
515            }
516            // Not an issue file: hand it to the read-only viewer, which can page
517            // anything. A viewer that cannot be resolved is a status message, not a
518            // failed key.
519            Err(_) => match crate::editor::resolve_viewer(self.store.config.viewer.as_deref()) {
520                Ok(_viewer) => Ok(Action::OpenViewerAt(path)),
521                Err(e) => {
522                    self.status_msg = Some(format!("viewer: {e}"));
523                    Ok(Action::None)
524                }
525            },
526        }
527    }
528
529    /// Push the current pane onto the back stack, bounded (tix #1513).
530    pub(in crate::app) fn push_detail_snapshot(&mut self) {
531        let Some(current) = self.detail.as_ref() else {
532            return;
533        };
534        if self.nav_stack.len() >= MAX_DETAIL_NAV {
535            self.nav_stack.remove(0);
536        }
537        self.nav_stack.push(current.clone());
538    }
539
540    /// Pop the back stack and restore the previous pane (tix #1513).
541    pub(in crate::app) fn nav_back(&mut self) {
542        match self.nav_stack.pop() {
543            Some(previous) => {
544                self.detail = Some(previous);
545                self.mode = Mode::Detail;
546                self.related_picker = None;
547            }
548            // Said rather than swallowed: a back key that does nothing and says
549            // nothing is indistinguishable from a missing binding.
550            None => self.status_msg = Some("no previous issue".to_string()),
551        }
552    }
553}