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}