1use std::cell::Cell;
2use std::collections::BTreeMap;
3#[cfg(not(test))]
4use std::collections::VecDeque;
5use std::fmt::Write as _;
6use std::io::Write as _;
7use std::path::PathBuf;
8
9use crate::analysis::Analyzer;
10use crate::analysis::git_history::{
11 GitCommitRecord, HistoryBeadCompat, HistoryCommitCompat, HistoryMilestonesCompat,
12 build_workspace_id_aliases, correlate_histories_with_git_aliases, finalize_history_entries,
13 load_git_commits,
14};
15use crate::analysis::history::IssueHistory;
16use crate::analysis::triage::TriageOptions;
17use crate::loader;
18use crate::model::{Issue, Sprint};
19#[cfg(not(test))]
20use crate::robot::compute_data_hash;
21use crate::{BvrError, Result};
22use chrono::{DateTime, Utc};
23use ftui::core::event::{
24 Event, KeyCode, KeyEvent, KeyEventKind, Modifiers, MouseButton, MouseEvent, MouseEventKind,
25};
26use ftui::core::geometry::Rect;
27use ftui::layout::{Constraint, Flex};
28use ftui::render::frame::Frame;
29#[cfg(not(test))]
30use ftui::runtime::TaskSpec;
31use ftui::runtime::{App, Cmd, Model, ScreenMode};
32use ftui::text::{
33 Line as RichLine, Span as RichSpan, Text as RichText, display_width, truncate_to_width,
34 truncate_with_ellipsis,
35};
36use ftui::widgets::Widget;
37use ftui::widgets::block::Block;
38use ftui::widgets::paragraph::Paragraph;
39
40#[cfg(not(test))]
41use std::sync::{
42 Arc,
43 atomic::{AtomicBool, Ordering},
44};
45
46#[derive(Debug, Clone)]
47pub struct BackgroundModeConfig {
48 pub beads_file: Option<PathBuf>,
49 pub workspace_config: Option<PathBuf>,
50 pub repo_path: Option<PathBuf>,
51 pub repo_filter: Option<String>,
52 pub poll_interval_ms: u64,
53}
54
55impl BackgroundModeConfig {
56 pub const DEFAULT_POLL_INTERVAL_MS: u64 = 2_000;
57
58 #[cfg(not(test))]
59 fn normalized(mut self) -> Self {
60 if self.poll_interval_ms == 0 {
61 self.poll_interval_ms = Self::DEFAULT_POLL_INTERVAL_MS;
62 }
63 self
64 }
65
66 #[cfg(not(test))]
67 fn poll_interval(&self) -> std::time::Duration {
68 std::time::Duration::from_millis(self.poll_interval_ms.max(1))
69 }
70
71 #[cfg(not(test))]
72 fn load_issues(&self) -> Result<Vec<Issue>> {
73 let issues = if let Some(path) = self.beads_file.as_deref() {
74 loader::load_issues_from_file(path)?
75 } else if let Some(path) = self.workspace_config.as_deref() {
76 loader::load_workspace_issues(path)?
77 } else {
78 loader::load_issues(self.repo_path.as_deref())?
79 };
80
81 if let Some(repo_filter) = self.repo_filter.as_deref() {
82 Ok(filter_issues_by_repo(issues, repo_filter))
83 } else {
84 Ok(issues)
85 }
86 }
87}
88
89#[cfg(not(test))]
90fn filter_issues_by_repo(issues: Vec<Issue>, repo_filter: &str) -> Vec<Issue> {
91 let filter = repo_filter.trim().to_ascii_lowercase();
92 if filter.is_empty() {
93 return issues;
94 }
95
96 let needs_flexible_match =
97 !filter.ends_with('-') && !filter.ends_with(':') && !filter.ends_with('_');
98 let with_dash = format!("{filter}-");
99 let with_colon = format!("{filter}:");
100 let with_underscore = format!("{filter}_");
101
102 issues
103 .into_iter()
104 .filter(|issue| {
105 let id = issue.id.to_ascii_lowercase();
106 if id.starts_with(&filter) {
107 return true;
108 }
109 if needs_flexible_match
110 && (id.starts_with(&with_dash)
111 || id.starts_with(&with_colon)
112 || id.starts_with(&with_underscore))
113 {
114 return true;
115 }
116
117 let source_repo = issue.source_repo.trim();
118 if source_repo.is_empty() || source_repo == "." {
119 return false;
120 }
121
122 let source_repo = source_repo.to_ascii_lowercase();
123 if source_repo.starts_with(&filter) {
124 return true;
125 }
126
127 needs_flexible_match
128 && (source_repo.starts_with(&with_dash)
129 || source_repo.starts_with(&with_colon)
130 || source_repo.starts_with(&with_underscore))
131 })
132 .collect()
133}
134
135#[cfg(not(test))]
136#[derive(Debug)]
137struct BackgroundRuntimeState {
138 config: BackgroundModeConfig,
139 in_flight: bool,
140 cancel_requested: Arc<AtomicBool>,
141 last_data_hash: String,
142 timeline: VecDeque<String>,
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146enum BackgroundTickDecision {
147 Stop,
148 TickOnly,
149 ReloadAndTick,
150}
151
152fn decide_background_tick(cancel_requested: bool, in_flight: bool) -> BackgroundTickDecision {
153 if cancel_requested {
154 BackgroundTickDecision::Stop
155 } else if in_flight {
156 BackgroundTickDecision::TickOnly
157 } else {
158 BackgroundTickDecision::ReloadAndTick
159 }
160}
161
162fn should_apply_background_reload(
163 cancel_requested: bool,
164 new_hash: &str,
165 previous_hash: &str,
166) -> bool {
167 !cancel_requested && new_hash != previous_hash
168}
169
170fn background_warning_message(cancel_requested: bool, error: &str) -> Option<String> {
171 if cancel_requested || error == "canceled" {
172 None
173 } else {
174 Some(format!("background reload warning: {error}"))
175 }
176}
177
178#[cfg(test)]
179fn sprint_reference_now() -> DateTime<Utc> {
180 chrono::DateTime::parse_from_rfc3339("2026-03-09T00:00:00Z")
181 .expect("valid fixed sprint test timestamp")
182 .with_timezone(&Utc)
183}
184
185#[cfg(not(test))]
186fn sprint_reference_now() -> DateTime<Utc> {
187 Utc::now()
188}
189
190#[cfg(test)]
191fn ui_reference_now() -> DateTime<Utc> {
192 chrono::DateTime::parse_from_rfc3339("2026-04-02T00:00:00Z")
193 .expect("valid fixed ui test timestamp")
194 .with_timezone(&Utc)
195}
196
197#[cfg(not(test))]
198fn ui_reference_now() -> DateTime<Utc> {
199 Utc::now()
200}
201
202#[cfg(not(test))]
203const BACKGROUND_TIMELINE_MAX_EVENTS: usize = 32;
204
205#[cfg(not(test))]
206fn background_timeline_entry(event: &str) -> String {
207 let ts = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
208 format!("{ts} | {event}")
209}
210
211#[cfg(not(test))]
212fn push_background_timeline(runtime: &mut BackgroundRuntimeState, event: &str) -> String {
213 let entry = background_timeline_entry(event);
214 runtime.timeline.push_back(entry.clone());
215 while runtime.timeline.len() > BACKGROUND_TIMELINE_MAX_EVENTS {
216 runtime.timeline.pop_front();
217 }
218 entry
219}
220
221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
227enum Breakpoint {
228 Narrow,
230 Medium,
232 Wide,
234}
235
236impl Breakpoint {
237 fn from_width(w: u16) -> Self {
238 if w < 80 {
239 Self::Narrow
240 } else if w < 120 {
241 Self::Medium
242 } else {
243 Self::Wide
244 }
245 }
246
247 fn list_pct(self) -> f32 {
249 match self {
250 Self::Narrow => 35.0,
251 Self::Medium => 42.0,
252 Self::Wide => 38.0,
253 }
254 }
255
256 #[cfg(test)]
257 fn detail_pct(self) -> f32 {
259 100.0 - self.list_pct()
260 }
261}
262
263#[derive(Debug, Clone, Copy, PartialEq)]
264struct PaneSplitState {
265 narrow_list_pct: f32,
266 medium_list_pct: f32,
267 wide_list_pct: f32,
268 history_standard: [f32; 3],
269 history_wide_git: [f32; 3],
270 history_wide_bead: [f32; 4],
271}
272
273impl Default for PaneSplitState {
274 fn default() -> Self {
275 Self {
276 narrow_list_pct: Breakpoint::Narrow.list_pct(),
277 medium_list_pct: Breakpoint::Medium.list_pct(),
278 wide_list_pct: Breakpoint::Wide.list_pct(),
279 history_standard: [30.0, 35.0, 35.0],
280 history_wide_git: [25.0, 30.0, 45.0],
281 history_wide_bead: [20.0, 22.0, 25.0, 33.0],
282 }
283 }
284}
285
286impl PaneSplitState {
287 fn two_pane_list_pct(self, breakpoint: Breakpoint) -> f32 {
288 match breakpoint {
289 Breakpoint::Narrow => self.narrow_list_pct,
290 Breakpoint::Medium => self.medium_list_pct,
291 Breakpoint::Wide => self.wide_list_pct,
292 }
293 }
294
295 fn two_pane_detail_pct(self, breakpoint: Breakpoint) -> f32 {
296 100.0 - self.two_pane_list_pct(breakpoint)
297 }
298
299 fn adjust_two_pane(&mut self, breakpoint: Breakpoint, delta_pct: f32) -> bool {
300 let list = self.two_pane_list_pct(breakpoint);
301 let clamped = (list + delta_pct).clamp(25.0, 75.0);
302 if (clamped - list).abs() < f32::EPSILON {
303 return false;
304 }
305 match breakpoint {
306 Breakpoint::Narrow => self.narrow_list_pct = clamped,
307 Breakpoint::Medium => self.medium_list_pct = clamped,
308 Breakpoint::Wide => self.wide_list_pct = clamped,
309 }
310 true
311 }
312
313 fn history_pcts(self, layout: HistoryLayout, view_mode: HistoryViewMode) -> PaneSplitPreset {
314 match (layout, view_mode) {
315 (HistoryLayout::Wide, HistoryViewMode::Bead) => {
316 PaneSplitPreset::Four(self.history_wide_bead)
317 }
318 (HistoryLayout::Wide, HistoryViewMode::Git) => {
319 PaneSplitPreset::Three(self.history_wide_git)
320 }
321 (HistoryLayout::Standard, _) => PaneSplitPreset::Three(self.history_standard),
322 (HistoryLayout::Narrow, _) => {
323 PaneSplitPreset::Two([self.medium_list_pct, 100.0 - self.medium_list_pct])
324 }
325 }
326 }
327
328 fn adjust_history(
329 &mut self,
330 layout: HistoryLayout,
331 view_mode: HistoryViewMode,
332 focus: FocusPane,
333 delta_pct: f32,
334 ) -> bool {
335 match (layout, view_mode) {
336 (HistoryLayout::Wide, HistoryViewMode::Bead) => {
337 let (primary, secondary) = match focus {
338 FocusPane::List => (0, 3),
339 FocusPane::Middle => (2, 3),
340 FocusPane::Detail => (3, 2),
341 };
342 adjust_split_pair(
343 &mut self.history_wide_bead,
344 primary,
345 secondary,
346 delta_pct,
347 15.0,
348 )
349 }
350 (HistoryLayout::Standard, _) | (HistoryLayout::Wide, HistoryViewMode::Git) => {
351 let (primary, secondary) = match focus {
352 FocusPane::List => (0, 2),
353 FocusPane::Middle => (1, 2),
354 FocusPane::Detail => (2, 1),
355 };
356 let target = if matches!(layout, HistoryLayout::Wide) {
357 &mut self.history_wide_git
358 } else {
359 &mut self.history_standard
360 };
361 adjust_split_pair(target, primary, secondary, delta_pct, 18.0)
362 }
363 (HistoryLayout::Narrow, _) => false,
364 }
365 }
366}
367
368#[derive(Debug, Clone, Copy, PartialEq)]
369enum PaneSplitPreset {
370 Two([f32; 2]),
371 Three([f32; 3]),
372 Four([f32; 4]),
373}
374
375#[derive(Debug, Clone, Copy, PartialEq, Eq)]
376enum SplitterTarget {
377 TwoPane { breakpoint: Breakpoint },
378 HistoryThree { wide: bool, divider: usize },
379 HistoryFour { divider: usize },
380}
381
382#[derive(Debug, Clone, Copy, PartialEq, Eq)]
383struct SplitterHitBox {
384 target: SplitterTarget,
385 rect: Rect,
386}
387
388#[derive(Debug, Clone, Copy, PartialEq, Eq)]
389struct HeaderModeTab {
390 mode: ViewMode,
391 rect: Rect,
392}
393
394impl PaneSplitState {
395 fn adjust_splitter_target(&mut self, target: SplitterTarget, delta_pct: f32) -> bool {
396 match target {
397 SplitterTarget::TwoPane { breakpoint } => self.adjust_two_pane(breakpoint, delta_pct),
398 SplitterTarget::HistoryThree { wide, divider } => {
399 let target = if wide {
400 &mut self.history_wide_git
401 } else {
402 &mut self.history_standard
403 };
404 adjust_split_pair(target, divider, divider + 1, delta_pct, 18.0)
405 }
406 SplitterTarget::HistoryFour { divider } => adjust_split_pair(
407 &mut self.history_wide_bead,
408 divider,
409 divider + 1,
410 delta_pct,
411 15.0,
412 ),
413 }
414 }
415}
416
417fn adjust_split_pair<const N: usize>(
418 ratios: &mut [f32; N],
419 primary: usize,
420 secondary: usize,
421 delta_pct: f32,
422 min_pct: f32,
423) -> bool {
424 let max_increase = ratios[secondary] - min_pct;
425 let max_decrease = ratios[primary] - min_pct;
426 let applied = delta_pct.clamp(-max_decrease, max_increase);
427 if applied.abs() < f32::EPSILON {
428 return false;
429 }
430 ratios[primary] += applied;
431 ratios[secondary] -= applied;
432 true
433}
434
435thread_local! {
436 static LAST_VIEW_WIDTH: Cell<u16> = const { Cell::new(80) };
437 static LAST_VIEW_HEIGHT: Cell<u16> = const { Cell::new(24) };
438 static LAST_DETAIL_CONTENT_AREA: Cell<Rect> = const { Cell::new(Rect::new(0, 0, 0, 0)) };
439 static PANE_SPLIT_STATE: Cell<PaneSplitState> = const { Cell::new(PaneSplitState {
440 narrow_list_pct: 35.0,
441 medium_list_pct: 42.0,
442 wide_list_pct: 38.0,
443 history_standard: [30.0, 35.0, 35.0],
444 history_wide_git: [25.0, 30.0, 45.0],
445 history_wide_bead: [20.0, 22.0, 25.0, 33.0],
446 }) };
447}
448
449fn record_view_size(width: u16, height: u16) {
450 LAST_VIEW_WIDTH.with(|cell| cell.set(width));
451 LAST_VIEW_HEIGHT.with(|cell| cell.set(height));
452}
453
454fn record_detail_content_area(area: Rect) {
455 LAST_DETAIL_CONTENT_AREA.with(|cell| cell.set(area));
456}
457
458fn cached_detail_content_area() -> Rect {
459 LAST_DETAIL_CONTENT_AREA.with(Cell::get)
460}
461
462fn cached_view_width() -> u16 {
463 LAST_VIEW_WIDTH.with(Cell::get)
464}
465
466fn cached_view_height() -> u16 {
467 LAST_VIEW_HEIGHT.with(Cell::get)
468}
469
470fn pane_split_state() -> PaneSplitState {
471 PANE_SPLIT_STATE.with(Cell::get)
472}
473
474fn set_pane_split_state(state: PaneSplitState) {
475 PANE_SPLIT_STATE.with(|cell| cell.set(state));
476}
477
478const fn block_inner_rect(area: Rect) -> Rect {
479 Rect::new(
480 area.x.saturating_add(1),
481 area.y.saturating_add(1),
482 area.width.saturating_sub(2),
483 area.height.saturating_sub(2),
484 )
485}
486
487fn saturating_scroll_offset(offset: usize) -> u16 {
488 u16::try_from(offset).unwrap_or(u16::MAX)
489}
490
491const fn rect_contains(area: Rect, x: u16, y: u16) -> bool {
492 x >= area.x
493 && y >= area.y
494 && x < area.x.saturating_add(area.width)
495 && y < area.y.saturating_add(area.height)
496}
497
498fn splitter_rect_between(left: Rect, right: Rect) -> Rect {
499 let x = left.x.saturating_add(left.width).saturating_sub(1);
500 let max_right = right.x.saturating_add(right.width);
501 let width = if x < max_right {
502 (max_right - x).min(2)
503 } else {
504 1
505 };
506 Rect::new(x, left.y, width.max(1), left.height)
507}
508
509fn splitter_hit_boxes(app: &BvrApp, width: u16, height: u16) -> Vec<SplitterHitBox> {
510 let full = Rect::from_size(width, height);
511 let rows = Flex::vertical()
512 .constraints([
513 Constraint::Fixed(1),
514 Constraint::Min(3),
515 Constraint::Fixed(1),
516 ])
517 .split(full);
518 let body = rows[1];
519 let bp = Breakpoint::from_width(width);
520 let split_state = pane_split_state();
521 let graph_single_pane = matches!(app.mode, ViewMode::Graph) && matches!(bp, Breakpoint::Narrow);
522 let history_layout = if matches!(app.mode, ViewMode::History) {
523 HistoryLayout::from_width(body.width)
524 } else {
525 HistoryLayout::Narrow
526 };
527 let history_multi_pane =
528 matches!(app.mode, ViewMode::History) && history_layout.has_middle_pane();
529
530 if graph_single_pane {
531 return Vec::new();
532 }
533
534 if history_multi_pane {
535 if matches!(history_layout, HistoryLayout::Wide)
536 && matches!(app.history_view_mode, HistoryViewMode::Bead)
537 {
538 let PaneSplitPreset::Four(pcts) =
539 split_state.history_pcts(history_layout, app.history_view_mode)
540 else {
541 unreachable!("wide bead history should use four-pane split");
542 };
543 let panes = Flex::horizontal()
544 .constraints([
545 Constraint::Percentage(pcts[0]),
546 Constraint::Percentage(pcts[1]),
547 Constraint::Percentage(pcts[2]),
548 Constraint::Percentage(pcts[3]),
549 ])
550 .split(body);
551 return vec![
552 SplitterHitBox {
553 target: SplitterTarget::HistoryFour { divider: 0 },
554 rect: splitter_rect_between(panes[0], panes[1]),
555 },
556 SplitterHitBox {
557 target: SplitterTarget::HistoryFour { divider: 1 },
558 rect: splitter_rect_between(panes[1], panes[2]),
559 },
560 SplitterHitBox {
561 target: SplitterTarget::HistoryFour { divider: 2 },
562 rect: splitter_rect_between(panes[2], panes[3]),
563 },
564 ];
565 }
566
567 let PaneSplitPreset::Three(pane_widths) =
568 split_state.history_pcts(history_layout, app.history_view_mode)
569 else {
570 unreachable!("multi-pane history should use three-pane split");
571 };
572 let panes = Flex::horizontal()
573 .constraints([
574 Constraint::Percentage(pane_widths[0]),
575 Constraint::Percentage(pane_widths[1]),
576 Constraint::Percentage(pane_widths[2]),
577 ])
578 .split(body);
579 return vec![
580 SplitterHitBox {
581 target: SplitterTarget::HistoryThree {
582 wide: matches!(history_layout, HistoryLayout::Wide),
583 divider: 0,
584 },
585 rect: splitter_rect_between(panes[0], panes[1]),
586 },
587 SplitterHitBox {
588 target: SplitterTarget::HistoryThree {
589 wide: matches!(history_layout, HistoryLayout::Wide),
590 divider: 1,
591 },
592 rect: splitter_rect_between(panes[1], panes[2]),
593 },
594 ];
595 }
596
597 let panes = Flex::horizontal()
598 .constraints([
599 Constraint::Percentage(split_state.two_pane_list_pct(bp)),
600 Constraint::Percentage(split_state.two_pane_detail_pct(bp)),
601 ])
602 .split(body);
603 vec![SplitterHitBox {
604 target: SplitterTarget::TwoPane { breakpoint: bp },
605 rect: splitter_rect_between(panes[0], panes[1]),
606 }]
607}
608
609fn header_tab_candidate_modes(mode: ViewMode, bp: Breakpoint) -> Vec<ViewMode> {
610 let mut modes = match bp {
611 Breakpoint::Narrow => vec![
612 ViewMode::Main,
613 ViewMode::Board,
614 ViewMode::Insights,
615 ViewMode::Graph,
616 ViewMode::History,
617 ],
618 Breakpoint::Medium => vec![
619 ViewMode::Main,
620 ViewMode::Board,
621 ViewMode::Insights,
622 ViewMode::Graph,
623 ViewMode::History,
624 ViewMode::Actionable,
625 ViewMode::Attention,
626 ViewMode::Tree,
627 ViewMode::Sprint,
628 ],
629 Breakpoint::Wide => ViewMode::navigation_order().to_vec(),
630 };
631 if !modes.contains(&mode) {
632 modes.push(mode);
633 }
634 modes
635}
636
637fn header_mode_tabs(app: &BvrApp, width: u16) -> Vec<HeaderModeTab> {
638 let bp = Breakpoint::from_width(width);
639 let reserved_width = match bp {
642 Breakpoint::Narrow => 16u16,
643 Breakpoint::Medium => 70u16,
644 Breakpoint::Wide if width < 132 => 68u16,
645 Breakpoint::Wide => 56u16,
646 };
647 let max_x = width.saturating_sub(reserved_width);
648 let mut x = 4u16;
649 let mut tabs = Vec::new();
650
651 for mode in header_tab_candidate_modes(app.mode, bp) {
652 let label = mode.tab_text(bp);
653 let tab_width = u16::try_from(display_width(&label)).unwrap_or(u16::MAX);
654 if tab_width == 0 {
655 continue;
656 }
657
658 let next_end = x.saturating_add(tab_width);
659 if !tabs.is_empty() && next_end >= max_x {
660 break;
661 }
662
663 tabs.push(HeaderModeTab {
664 mode,
665 rect: Rect::new(x, 0, tab_width, 1),
666 });
667 x = next_end.saturating_add(1);
668 }
669
670 if !tabs.iter().any(|tab| tab.mode == app.mode) {
671 let label = app.mode.tab_text(bp);
672 let tab_width = u16::try_from(display_width(&label)).unwrap_or(u16::MAX);
673 let start_x = width.saturating_sub(tab_width.saturating_add(1));
674 tabs.push(HeaderModeTab {
675 mode: app.mode,
676 rect: Rect::new(start_x, 0, tab_width.min(width.saturating_sub(start_x)), 1),
677 });
678 }
679
680 tabs
681}
682
683fn is_http_url(value: &str) -> bool {
684 value.starts_with("https://") || value.starts_with("http://")
685}
686
687#[derive(Debug, Clone, Copy, PartialEq, Eq)]
688struct CommandHint<'a> {
689 key: &'a str,
690 desc: &'a str,
691}
692
693#[derive(Debug, Clone, Copy, PartialEq, Eq)]
694enum SemanticTone {
695 Neutral,
696 Accent,
697 Success,
698 Warning,
699 Danger,
700 Muted,
701}
702
703mod tokens {
709 use super::SemanticTone;
710 use ftui::{PackedRgba, Style};
711
712 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
715 pub enum ThemeMode {
716 Dark,
717 Light,
718 }
719
720 pub fn detect_theme() -> ThemeMode {
723 static CACHED: std::sync::OnceLock<ThemeMode> = std::sync::OnceLock::new();
724 *CACHED.get_or_init(|| match std::env::var("BV_THEME").as_deref() {
725 Ok("light") => ThemeMode::Light,
726 _ => ThemeMode::Dark,
727 })
728 }
729
730 fn is_light() -> bool {
731 detect_theme() == ThemeMode::Light
732 }
733
734 fn p(light: PackedRgba, dark: PackedRgba) -> PackedRgba {
736 if is_light() { light } else { dark }
737 }
738
739 pub const FG_DEFAULT: PackedRgba = PackedRgba::rgb(248, 248, 242); pub const FG_DEFAULT_LIGHT: PackedRgba = PackedRgba::rgb(26, 26, 26); pub const FG_DIM: PackedRgba = PackedRgba::rgb(136, 136, 136); pub const FG_ACCENT: PackedRgba = PackedRgba::rgb(189, 147, 249); pub const FG_ACCENT_LIGHT: PackedRgba = PackedRgba::rgb(107, 71, 217); pub const FG_SUCCESS: PackedRgba = PackedRgba::rgb(80, 250, 123); pub const FG_SUCCESS_LIGHT: PackedRgba = PackedRgba::rgb(0, 119, 0); pub const FG_WARNING: PackedRgba = PackedRgba::rgb(255, 184, 108); pub const FG_WARNING_LIGHT: PackedRgba = PackedRgba::rgb(176, 104, 0); pub const FG_ERROR: PackedRgba = PackedRgba::rgb(255, 85, 85); pub const FG_ERROR_LIGHT: PackedRgba = PackedRgba::rgb(204, 0, 0); pub const FG_INFO: PackedRgba = PackedRgba::rgb(139, 233, 253); pub const FG_INFO_LIGHT: PackedRgba = PackedRgba::rgb(0, 96, 128); pub const FG_MUTED: PackedRgba = PackedRgba::rgb(98, 114, 164); pub const FG_MUTED_LIGHT: PackedRgba = PackedRgba::rgb(102, 102, 102); pub const FG_SUBTEXT: PackedRgba = PackedRgba::rgb(191, 191, 191); pub const FG_SUBTEXT_LIGHT: PackedRgba = PackedRgba::rgb(85, 85, 85); pub const BG_SURFACE: PackedRgba = PackedRgba::rgb(54, 57, 73); pub const BG_SURFACE_LIGHT: PackedRgba = PackedRgba::rgb(232, 232, 232); pub const BG_HIGHLIGHT: PackedRgba = PackedRgba::rgb(68, 71, 90); pub const BG_HIGHLIGHT_LIGHT: PackedRgba = PackedRgba::rgb(208, 208, 208); pub const BG_DARK: PackedRgba = PackedRgba::rgb(30, 31, 41); pub const BG_SURFACE_ACCENT: PackedRgba = PackedRgba::rgb(42, 26, 68); pub const BG_SURFACE_SUCCESS: PackedRgba = PackedRgba::rgb(26, 61, 42); pub const BG_SURFACE_WARNING: PackedRgba = PackedRgba::rgb(61, 42, 26); pub const BG_SURFACE_DANGER: PackedRgba = PackedRgba::rgb(61, 26, 26); pub const BG_SURFACE_INFO: PackedRgba = PackedRgba::rgb(26, 51, 68); pub const BG_SURFACE_MUTED: PackedRgba = PackedRgba::rgb(42, 42, 61); pub const BG_SURFACE_ACCENT_L: PackedRgba = PackedRgba::rgb(232, 221, 255); pub const BG_SURFACE_SUCCESS_L: PackedRgba = PackedRgba::rgb(212, 237, 218); pub const BG_SURFACE_WARNING_L: PackedRgba = PackedRgba::rgb(255, 232, 204); pub const BG_SURFACE_DANGER_L: PackedRgba = PackedRgba::rgb(248, 215, 218); pub const BG_SURFACE_INFO_L: PackedRgba = PackedRgba::rgb(209, 236, 241); pub const BG_SURFACE_MUTED_L: PackedRgba = PackedRgba::rgb(226, 227, 229); pub fn status_fg(status: &str) -> PackedRgba {
786 let s = status.to_ascii_lowercase();
787 let s = s.as_str();
788 match s {
789 "open" => p(FG_SUCCESS_LIGHT, FG_SUCCESS),
790 "in_progress" => p(FG_INFO_LIGHT, FG_INFO),
791 "blocked" => p(FG_ERROR_LIGHT, FG_ERROR),
792 "deferred" | "draft" => p(FG_WARNING_LIGHT, FG_WARNING),
793 "pinned" => p(PackedRgba::rgb(0, 102, 204), PackedRgba::rgb(102, 153, 255)),
794 "hooked" => p(PackedRgba::rgb(0, 128, 128), PackedRgba::rgb(0, 206, 209)),
795 "review" => p(FG_ACCENT_LIGHT, FG_ACCENT),
796 "closed" => p(FG_SUBTEXT_LIGHT, FG_MUTED),
797 "tombstone" => p(FG_DIM, PackedRgba::rgb(68, 71, 90)),
798 _ => p(FG_DIM, FG_DIM),
799 }
800 }
801
802 pub fn status_bg(status: &str) -> PackedRgba {
803 let s = status.to_ascii_lowercase();
804 let s = s.as_str();
805 match s {
806 "open" => p(BG_SURFACE_SUCCESS_L, BG_SURFACE_SUCCESS),
807 "in_progress" => p(BG_SURFACE_INFO_L, BG_SURFACE_INFO),
808 "blocked" => p(BG_SURFACE_DANGER_L, BG_SURFACE_DANGER),
809 "deferred" | "draft" => p(BG_SURFACE_WARNING_L, BG_SURFACE_WARNING),
810 "pinned" => p(PackedRgba::rgb(204, 229, 255), PackedRgba::rgb(26, 42, 68)),
811 "hooked" => p(PackedRgba::rgb(204, 255, 255), PackedRgba::rgb(26, 61, 61)),
812 "review" => p(BG_SURFACE_ACCENT_L, BG_SURFACE_ACCENT),
813 "closed" => p(BG_SURFACE_MUTED_L, BG_SURFACE_MUTED),
814 "tombstone" => p(BG_HIGHLIGHT_LIGHT, BG_DARK),
815 _ => p(BG_SURFACE_LIGHT, BG_SURFACE),
816 }
817 }
818
819 pub fn status_badge_label(status: &str) -> &'static str {
821 let s = status.to_ascii_lowercase();
822 match s.as_str() {
823 "open" => "OPEN",
824 "in_progress" => "PROG",
825 "blocked" => "BLKD",
826 "deferred" => "DEFR",
827 "draft" => "DRFT",
828 "pinned" => "PIN ",
829 "hooked" => "HOOK",
830 "review" => "REVW",
831 "closed" => "DONE",
832 "tombstone" => "TOMB",
833 _ => "????",
834 }
835 }
836
837 pub const PRIO_P0: PackedRgba = FG_ERROR; pub const PRIO_P1: PackedRgba = FG_WARNING; pub const PRIO_P2: PackedRgba = PackedRgba::rgb(241, 250, 140); pub const PRIO_P3: PackedRgba = FG_SUCCESS; pub const PRIO_P4: PackedRgba = FG_MUTED; pub fn priority_fg(prio: u8) -> PackedRgba {
846 match prio {
847 0 => p(FG_ERROR_LIGHT, PRIO_P0),
848 1 => p(FG_WARNING_LIGHT, PRIO_P1),
849 2 => p(PackedRgba::rgb(128, 128, 0), PRIO_P2),
850 3 => p(FG_SUCCESS_LIGHT, PRIO_P3),
851 _ => p(FG_MUTED_LIGHT, PRIO_P4),
852 }
853 }
854
855 pub fn priority_bg(prio: u8) -> PackedRgba {
856 match prio {
857 0 => p(BG_SURFACE_DANGER_L, BG_SURFACE_DANGER),
858 1 => p(BG_SURFACE_WARNING_L, BG_SURFACE_WARNING),
859 2 => p(PackedRgba::rgb(255, 243, 205), PackedRgba::rgb(61, 61, 26)),
860 3 => p(BG_SURFACE_SUCCESS_L, BG_SURFACE_SUCCESS),
861 _ => p(BG_SURFACE_LIGHT, BG_SURFACE),
862 }
863 }
864
865 pub fn priority_style(prio: u8) -> Style {
866 Style::new().fg(priority_fg(prio)).bold()
867 }
868
869 pub fn priority_badge(prio: u8) -> Style {
871 Style::new()
872 .fg(priority_fg(prio))
873 .bg(priority_bg(prio))
874 .bold()
875 }
876
877 pub fn type_fg(issue_type: &str) -> PackedRgba {
880 let t = issue_type.to_ascii_lowercase();
881 match t.as_str() {
882 "bug" => p(FG_ERROR_LIGHT, FG_ERROR),
883 "feature" => p(FG_WARNING_LIGHT, FG_WARNING),
884 "task" => p(PackedRgba::rgb(128, 128, 0), PackedRgba::rgb(241, 250, 140)),
885 "epic" => p(FG_ACCENT_LIGHT, FG_ACCENT),
886 "docs" => p(FG_INFO_LIGHT, FG_INFO),
887 _ => p(FG_SUBTEXT_LIGHT, FG_SUBTEXT),
888 }
889 }
890
891 pub fn footer_key() -> Style {
895 Style::new()
896 .fg(p(
897 PackedRgba::rgb(51, 51, 51),
898 PackedRgba::rgb(224, 224, 232),
899 ))
900 .bold()
901 }
902
903 pub fn footer_hint() -> Style {
905 Style::new().fg(p(
906 PackedRgba::rgb(68, 68, 68),
907 PackedRgba::rgb(200, 200, 208),
908 ))
909 }
910
911 pub fn footer_sep() -> Style {
913 Style::new().fg(p(
914 PackedRgba::rgb(136, 136, 136),
915 PackedRgba::rgb(136, 136, 160),
916 ))
917 }
918
919 pub fn footer_dim() -> Style {
921 Style::new().fg(p(
922 PackedRgba::rgb(85, 85, 85),
923 PackedRgba::rgb(160, 160, 184),
924 ))
925 }
926
927 pub fn heatmap_color(score: f64) -> PackedRgba {
931 if score > 0.8 {
932 p(FG_ACCENT_LIGHT, FG_ACCENT)
933 } else if score > 0.5 {
934 p(FG_WARNING_LIGHT, FG_WARNING)
935 } else if score > 0.2 {
936 p(FG_INFO_LIGHT, FG_INFO)
937 } else {
938 p(FG_SUBTEXT_LIGHT, FG_MUTED)
939 }
940 }
941
942 const SPARK_CHARS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
945
946 pub fn render_sparkline(value: f64, width: usize) -> String {
948 let clamped = value.clamp(0.0, 1.0);
949 let total = (clamped * width as f64 * 8.0) as usize;
950 let full_blocks = total / 8;
951 let remainder = total % 8;
952 let mut s = String::with_capacity(width);
953 for _ in 0..full_blocks.min(width) {
954 s.push('█');
955 }
956 if full_blocks < width && remainder > 0 {
957 s.push(SPARK_CHARS[remainder.saturating_sub(1)]);
958 }
959 let drawn = s.chars().count();
960 for _ in drawn..width {
961 s.push(' ');
962 }
963 s
964 }
965
966 pub fn fg_default() -> PackedRgba {
971 p(FG_DEFAULT_LIGHT, FG_DEFAULT)
972 }
973 pub fn fg_dim() -> PackedRgba {
974 p(FG_MUTED_LIGHT, FG_DIM)
975 }
976 pub fn fg_accent() -> PackedRgba {
977 p(FG_ACCENT_LIGHT, FG_ACCENT)
978 }
979 pub fn bg_surface() -> PackedRgba {
980 p(BG_SURFACE_LIGHT, BG_SURFACE)
981 }
982 pub fn bg_highlight() -> PackedRgba {
983 p(BG_HIGHLIGHT_LIGHT, BG_HIGHLIGHT)
984 }
985
986 pub fn header() -> Style {
989 Style::new().fg(fg_accent()).bold()
990 }
991
992 pub fn header_bg() -> Style {
993 Style::new().fg(fg_accent()).bg(bg_surface()).bold()
994 }
995
996 pub fn footer() -> Style {
997 footer_hint()
998 }
999
1000 pub fn selected() -> Style {
1001 Style::new().fg(fg_default()).bg(bg_highlight()).bold()
1002 }
1003
1004 pub fn panel_title() -> Style {
1005 Style::new().fg(fg_default()).bold()
1006 }
1007
1008 pub fn panel_title_focused() -> Style {
1009 Style::new().fg(fg_accent()).bold()
1010 }
1011
1012 pub fn semantic_fg(tone: SemanticTone) -> PackedRgba {
1013 match tone {
1014 SemanticTone::Neutral => fg_default(),
1015 SemanticTone::Accent => fg_accent(),
1016 SemanticTone::Success => p(FG_SUCCESS_LIGHT, FG_SUCCESS),
1017 SemanticTone::Warning => p(FG_WARNING_LIGHT, FG_WARNING),
1018 SemanticTone::Danger => p(FG_ERROR_LIGHT, FG_ERROR),
1019 SemanticTone::Muted => fg_dim(),
1020 }
1021 }
1022
1023 pub fn semantic_bg(tone: SemanticTone) -> PackedRgba {
1024 match tone {
1025 SemanticTone::Neutral => bg_surface(),
1026 SemanticTone::Accent => p(BG_SURFACE_ACCENT_L, BG_SURFACE_ACCENT),
1027 SemanticTone::Success => p(BG_SURFACE_SUCCESS_L, BG_SURFACE_SUCCESS),
1028 SemanticTone::Warning => p(BG_SURFACE_WARNING_L, BG_SURFACE_WARNING),
1029 SemanticTone::Danger => p(BG_SURFACE_DANGER_L, BG_SURFACE_DANGER),
1030 SemanticTone::Muted => p(BG_SURFACE_MUTED_L, BG_SURFACE_MUTED),
1031 }
1032 }
1033
1034 pub fn chip_style(tone: SemanticTone) -> Style {
1035 Style::new()
1036 .fg(semantic_fg(tone))
1037 .bg(semantic_bg(tone))
1038 .bold()
1039 }
1040
1041 pub fn panel_border_for(tone: SemanticTone, focused: bool) -> Style {
1042 let tone = if focused { tone } else { SemanticTone::Muted };
1043 Style::new().fg(semantic_fg(tone))
1044 }
1045
1046 pub fn panel_title_for(tone: SemanticTone, focused: bool) -> Style {
1047 let tone = if focused { tone } else { SemanticTone::Neutral };
1048 Style::new().fg(semantic_fg(tone)).bold()
1049 }
1050
1051 pub fn status_style(status: &str) -> Style {
1052 Style::new().fg(status_fg(status))
1053 }
1054
1055 pub fn status_badge(status: &str) -> Style {
1057 Style::new()
1058 .fg(status_fg(status))
1059 .bg(status_bg(status))
1060 .bold()
1061 }
1062
1063 pub fn help_desc() -> Style {
1064 Style::new().fg(fg_default())
1065 }
1066
1067 pub fn dim() -> Style {
1068 Style::new().fg(fg_dim())
1069 }
1070}
1071
1072fn semantic_panel_block(title: &str, focused: bool, tone: SemanticTone) -> Block<'_> {
1073 Block::bordered()
1074 .title(title)
1075 .border_style(tokens::panel_border_for(tone, focused))
1076 .style(tokens::panel_title_for(tone, focused))
1077}
1078
1079fn push_chip(line: &mut RichLine, label: &str, tone: SemanticTone) {
1080 line.push_span(RichSpan::styled(
1081 truncate_display(label, 32),
1082 tokens::chip_style(tone),
1083 ));
1084}
1085
1086fn push_metric_chip(line: &mut RichLine, label: &str, value: &str, tone: SemanticTone) {
1087 push_chip(line, &format!("{label}={value}"), tone);
1088}
1089
1090fn build_header_text(app: &BvrApp, width: u16) -> RichText {
1091 let bp = Breakpoint::from_width(width);
1092 let visible_count = app.visible_issue_indices().len();
1093 let total_count = app.analyzer.issues.len();
1094 let mode_tabs = header_mode_tabs(app, width);
1095
1096 if matches!(bp, Breakpoint::Narrow) {
1097 let mut line = RichLine::new();
1098 line.push_span(RichSpan::styled("bvr", tokens::header()));
1099 line.push_span(RichSpan::raw(" "));
1100 for tab in &mode_tabs {
1101 push_chip(
1102 &mut line,
1103 &tab.mode.tab_text(bp),
1104 if tab.mode == app.mode {
1105 SemanticTone::Accent
1106 } else {
1107 SemanticTone::Muted
1108 },
1109 );
1110 line.push_span(RichSpan::raw(" "));
1111 }
1112 line.push_span(RichSpan::styled(" | ", tokens::dim()));
1113 push_chip(
1114 &mut line,
1115 &format!("{visible_count}/{total_count}"),
1116 SemanticTone::Neutral,
1117 );
1118 line.push_span(RichSpan::styled(" | ", tokens::dim()));
1119 push_chip(&mut line, app.list_filter.label(), SemanticTone::Muted);
1120 return RichText::from_lines([line]);
1121 }
1122
1123 let mut filter_label = app.list_filter.label().to_string();
1124 if let Some(ref label) = app.modal_label_filter {
1125 filter_label = format!("{filter_label}+label:{label}");
1126 }
1127 if let Some(ref repo) = app.modal_repo_filter {
1128 filter_label = format!("{filter_label}+repo:{repo}");
1129 }
1130 let mode_label = if matches!(app.mode, ViewMode::History) {
1131 format!("{} {}", app.mode.label(), app.history_view_mode.indicator())
1132 } else {
1133 app.mode.label().to_string()
1134 };
1135
1136 let mut line = RichLine::new();
1137 line.push_span(RichSpan::styled("bvr", tokens::header()));
1138 line.push_span(RichSpan::raw(" "));
1139 for tab in &mode_tabs {
1140 push_chip(
1141 &mut line,
1142 &tab.mode.tab_text(bp),
1143 if tab.mode == app.mode {
1144 SemanticTone::Accent
1145 } else {
1146 SemanticTone::Muted
1147 },
1148 );
1149 line.push_span(RichSpan::raw(" "));
1150 }
1151 line.push_span(RichSpan::styled("| mode=", tokens::dim()));
1152 push_chip(&mut line, &mode_label, SemanticTone::Accent);
1153 line.push_span(RichSpan::styled(" | focus=", tokens::dim()));
1154 push_chip(&mut line, app.focus.label(), SemanticTone::Warning);
1155 line.push_span(RichSpan::styled(" | ", tokens::dim()));
1156 push_metric_chip(
1157 &mut line,
1158 "issues",
1159 &format!("{visible_count}/{total_count}"),
1160 SemanticTone::Neutral,
1161 );
1162 if matches!(bp, Breakpoint::Medium | Breakpoint::Wide) {
1163 line.push_span(RichSpan::styled(" | ", tokens::dim()));
1164 push_metric_chip(&mut line, "filter", &filter_label, SemanticTone::Muted);
1165 }
1166 if matches!(bp, Breakpoint::Wide) && !app.slow_metrics_pending {
1167 line.push_span(RichSpan::styled(" | ", tokens::dim()));
1168 push_metric_chip(
1169 &mut line,
1170 "sort",
1171 app.list_sort.label(),
1172 SemanticTone::Neutral,
1173 );
1174 }
1175 if app.slow_metrics_pending {
1176 line.push_span(RichSpan::styled(" | metrics: ", tokens::dim()));
1177 push_chip(&mut line, "computing...", SemanticTone::Warning);
1178 }
1179 line.push_span(RichSpan::styled(" |", tokens::dim()));
1180 RichText::from_lines([line])
1181}
1182
1183#[cfg_attr(not(test), allow(dead_code))]
1189fn status_chip(status: &str) -> Vec<RichSpan<'static>> {
1192 let normalized = status.trim().to_ascii_lowercase();
1193 let (icon, label) = match normalized.as_str() {
1194 "open" => ("●", "open"),
1195 "in_progress" => ("▶", "prog"),
1196 "blocked" => ("■", "blkd"),
1197 "closed" => ("✔", "done"),
1198 "deferred" => ("◇", "defr"),
1199 "review" => ("◎", "revw"),
1200 "pinned" => ("⊤", "pind"),
1201 "tombstone" => ("†", "tomb"),
1202 "hooked" => ("⊙", "hook"),
1203 _ => ("?", "unkn"),
1204 };
1205 vec![
1206 RichSpan::styled(icon, tokens::status_style(&normalized)),
1207 RichSpan::styled(format!("{label}"), tokens::status_style(&normalized)),
1208 ]
1209}
1210
1211#[cfg_attr(not(test), allow(dead_code))]
1212fn priority_badge(priority: i32) -> RichSpan<'static> {
1215 let prio = priority.clamp(0, 4) as u8;
1216 RichSpan::styled(format!("P{prio}"), tokens::priority_style(prio).bold())
1217}
1218
1219#[cfg_attr(not(test), allow(dead_code))]
1220fn type_badge(issue_type: &str) -> RichSpan<'static> {
1223 let icon = type_icon(issue_type);
1224 RichSpan::styled(icon, ftui::Style::new().fg(tokens::type_fg(issue_type)))
1225}
1226
1227#[cfg_attr(not(test), allow(dead_code))]
1228fn metric_strip(label: &str, value: f64, max_value: f64) -> Vec<RichSpan<'static>> {
1231 let bar = mini_bar(value, max_value);
1232 let formatted = format!("{value:.2}");
1233 vec![
1234 RichSpan::styled(format!("{label} "), tokens::dim()),
1235 RichSpan::raw(bar),
1236 RichSpan::styled(format!(" {formatted}"), tokens::dim()),
1237 ]
1238}
1239
1240#[cfg_attr(not(test), allow(dead_code))]
1241fn blocker_indicator(open_blockers: usize, blocks_count: usize) -> Vec<RichSpan<'static>> {
1244 if open_blockers > 0 {
1245 vec![RichSpan::styled(
1246 format!("⊘{open_blockers}"),
1247 tokens::status_style("blocked"),
1248 )]
1249 } else if blocks_count > 0 {
1250 vec![RichSpan::styled(
1251 format!("↓{blocks_count}"),
1252 tokens::status_style("open"),
1253 )]
1254 } else {
1255 Vec::new()
1256 }
1257}
1258
1259#[cfg_attr(not(test), allow(dead_code))]
1260fn section_separator(width: usize) -> RichLine {
1262 let rule = "─".repeat(width.min(120));
1263 RichLine::from_spans([RichSpan::styled(rule, tokens::dim())])
1264}
1265
1266#[cfg_attr(not(test), allow(dead_code))]
1267fn panel_header<'a>(title: &'a str, subtitle: Option<&'a str>) -> RichLine {
1269 let mut spans = vec![RichSpan::styled(title, tokens::panel_title().bold())];
1270 if let Some(sub) = subtitle {
1271 spans.push(RichSpan::styled(format!(" {sub}"), tokens::dim()));
1272 }
1273 RichLine::from_spans(spans)
1274}
1275
1276#[cfg_attr(not(test), allow(dead_code))]
1277fn label_chips(labels: &[String]) -> Vec<RichSpan<'static>> {
1279 let mut spans = Vec::new();
1280 for (i, label) in labels.iter().enumerate() {
1281 if i > 0 {
1282 spans.push(RichSpan::styled(" ", tokens::dim()));
1283 }
1284 spans.push(RichSpan::styled(format!("[{label}]"), tokens::header()));
1285 }
1286 spans
1287}
1288
1289#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1290enum ScanLineVariant {
1291 Narrow,
1292 Medium,
1293 Wide,
1294}
1295
1296impl ScanLineVariant {
1297 fn from_width(width: usize) -> Self {
1298 if width < 54 {
1299 Self::Narrow
1300 } else if width < 92 {
1301 Self::Medium
1302 } else {
1303 Self::Wide
1304 }
1305 }
1306}
1307
1308#[derive(Debug, Clone, PartialEq, Eq)]
1309struct ScanSegment {
1310 label: String,
1311 kind: ScanSegmentKind,
1312}
1313
1314#[derive(Debug, Clone, Copy, PartialEq)]
1315struct ScanLineContext {
1316 open_blockers: usize,
1317 blocks_count: usize,
1318 triage_rank: usize,
1319 pagerank_rank: usize,
1320 critical_depth: usize,
1321 graph_score: f64,
1322 search_match_position: Option<usize>,
1323 total_search_matches: usize,
1324 diff_tag: Option<DiffTag>,
1325 available_width: usize,
1326}
1327
1328#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1329enum DiffTag {
1330 New,
1331 Modified,
1332 Closed,
1333 Reopened,
1334}
1335
1336#[derive(Debug, Clone, PartialEq, Eq)]
1337enum ScanSegmentKind {
1338 Marker {
1339 selected: bool,
1340 },
1341 Chip(SemanticTone),
1342 Dim,
1343 Title {
1344 selected: bool,
1345 },
1346 Priority,
1347 Type,
1348 StatusBadge {
1350 status: String,
1351 },
1352 Sparkline,
1353}
1354
1355fn push_scan_segment(line: &mut RichLine, segment: &ScanSegment, row_selected: bool) {
1356 let base_style = match segment.kind {
1357 ScanSegmentKind::Marker { selected } => {
1358 if selected {
1359 tokens::selected()
1360 } else {
1361 tokens::dim()
1362 }
1363 }
1364 ScanSegmentKind::Chip(tone) => tokens::chip_style(tone),
1365 ScanSegmentKind::Dim => tokens::dim(),
1366 ScanSegmentKind::Title { selected } => {
1367 if selected {
1368 tokens::panel_title()
1369 } else {
1370 tokens::help_desc()
1371 }
1372 }
1373 ScanSegmentKind::Priority => {
1374 let prio = segment
1375 .label
1376 .trim_start_matches('P')
1377 .parse::<i32>()
1378 .unwrap_or_default();
1379 tokens::priority_badge(prio as u8)
1380 }
1381 ScanSegmentKind::Type => {
1382 let type_name = match segment.label.as_str() {
1384 "B" => "bug",
1385 "F" => "feature",
1386 "T" => "task",
1387 "E" => "epic",
1388 "Q" => "question",
1389 "D" => "docs",
1390 "R" => "refactor",
1391 _ => "",
1392 };
1393 ftui::Style::new().fg(tokens::type_fg(type_name))
1394 }
1395 ScanSegmentKind::StatusBadge { ref status } => tokens::status_badge(status),
1396 ScanSegmentKind::Sparkline => {
1397 let char_count = segment.label.chars().count().max(1);
1399 let filled = segment.label.chars().filter(|c| *c != ' ').count();
1400 let score = filled as f64 / char_count as f64;
1401 ftui::Style::new().fg(tokens::heatmap_color(score))
1402 }
1403 };
1404 let style = if row_selected {
1406 base_style.bg(tokens::bg_highlight())
1407 } else {
1408 base_style
1409 };
1410 line.push_span(RichSpan::styled(segment.label.clone(), style));
1411}
1412
1413fn scan_segments_width(segments: &[ScanSegment]) -> usize {
1414 if segments.is_empty() {
1415 return 0;
1416 }
1417 segments
1418 .iter()
1419 .map(|segment| display_width(&segment.label))
1420 .sum::<usize>()
1421 + segments.len().saturating_sub(1)
1422}
1423
1424fn issue_action_state(issue: &crate::model::Issue, open_blockers: usize) -> &'static str {
1425 if issue.is_closed_like() {
1426 "closed"
1427 } else if open_blockers > 0 {
1428 "blocked"
1429 } else {
1430 "ready"
1431 }
1432}
1433
1434fn action_state_tone(state: &str) -> SemanticTone {
1435 match state {
1436 "ready" => SemanticTone::Success,
1437 "blocked" => SemanticTone::Danger,
1438 "closed" => SemanticTone::Muted,
1439 _ => SemanticTone::Neutral,
1440 }
1441}
1442
1443fn dependency_signal_segments(
1444 open_blockers: usize,
1445 blocks_count: usize,
1446 variant: ScanLineVariant,
1447) -> Vec<ScanSegment> {
1448 let mut segments = Vec::new();
1449 if open_blockers > 0 {
1450 segments.push(ScanSegment {
1451 label: format!("⊘{open_blockers}"),
1452 kind: ScanSegmentKind::Chip(SemanticTone::Danger),
1453 });
1454 }
1455 if blocks_count > 0 && !matches!(variant, ScanLineVariant::Narrow) {
1456 segments.push(ScanSegment {
1457 label: format!("↓{blocks_count}"),
1458 kind: ScanSegmentKind::Chip(SemanticTone::Accent),
1459 });
1460 }
1461 segments
1462}
1463
1464fn issue_label_summary(issue: &crate::model::Issue) -> Option<String> {
1465 issue.labels.first().map(|label| {
1466 if issue.labels.len() > 1 {
1467 format!(
1468 "[{}+{}]",
1469 truncate_display(label, 10),
1470 issue.labels.len() - 1
1471 )
1472 } else {
1473 format!("[{}]", truncate_display(label, 12))
1474 }
1475 })
1476}
1477
1478fn issue_scan_line(
1481 issue: &crate::model::Issue,
1482 is_selected: bool,
1483 context: ScanLineContext,
1484) -> RichLine {
1485 let variant = ScanLineVariant::from_width(context.available_width);
1486 let action_state = issue_action_state(issue, context.open_blockers);
1487 let mut prefix = vec![
1488 ScanSegment {
1489 label: if is_selected {
1490 "▸".to_string()
1491 } else {
1492 " ".to_string()
1493 },
1494 kind: ScanSegmentKind::Marker {
1495 selected: is_selected,
1496 },
1497 },
1498 ScanSegment {
1499 label: format!("#{:02}", context.triage_rank),
1500 kind: ScanSegmentKind::Chip(SemanticTone::Accent),
1501 },
1502 ScanSegment {
1503 label: action_state.to_string(),
1504 kind: ScanSegmentKind::Chip(action_state_tone(action_state)),
1505 },
1506 ScanSegment {
1507 label: format!("P{}", issue.priority.clamp(0, 4)),
1508 kind: ScanSegmentKind::Priority,
1509 },
1510 ScanSegment {
1511 label: truncate_display(&issue.id, 14),
1512 kind: ScanSegmentKind::Dim,
1513 },
1514 ];
1515
1516 if !matches!(variant, ScanLineVariant::Narrow) {
1517 prefix.push(ScanSegment {
1518 label: type_icon(&issue.issue_type).to_string(),
1519 kind: ScanSegmentKind::Type,
1520 });
1521 }
1522 if matches!(variant, ScanLineVariant::Wide) {
1523 prefix.push(ScanSegment {
1524 label: tokens::status_badge_label(&issue.status).to_string(),
1525 kind: ScanSegmentKind::StatusBadge {
1526 status: issue.status.clone(),
1527 },
1528 });
1529 if context.graph_score > 0.0 {
1531 prefix.push(ScanSegment {
1532 label: tokens::render_sparkline(context.graph_score, 5),
1533 kind: ScanSegmentKind::Sparkline,
1534 });
1535 }
1536 }
1537
1538 let mut suffix =
1539 dependency_signal_segments(context.open_blockers, context.blocks_count, variant);
1540 if !issue.assignee.trim().is_empty() {
1541 suffix.push(ScanSegment {
1542 label: format!("@{}", truncate_display(issue.assignee.trim(), 12)),
1543 kind: ScanSegmentKind::Chip(SemanticTone::Neutral),
1544 });
1545 } else if matches!(variant, ScanLineVariant::Wide) {
1546 suffix.push(ScanSegment {
1547 label: "@unassigned".to_string(),
1548 kind: ScanSegmentKind::Chip(SemanticTone::Muted),
1549 });
1550 }
1551 if matches!(variant, ScanLineVariant::Medium | ScanLineVariant::Wide) {
1552 if !issue.comments.is_empty() {
1554 suffix.push(ScanSegment {
1555 label: format!("💬{}", issue.comments.len()),
1556 kind: ScanSegmentKind::Dim,
1557 });
1558 }
1559 suffix.push(ScanSegment {
1561 label: format!(
1562 "↻{}",
1563 format_compact_timestamp(issue.updated_at.or(issue.created_at))
1564 ),
1565 kind: ScanSegmentKind::Dim,
1566 });
1567 }
1568 if matches!(variant, ScanLineVariant::Wide) {
1569 suffix.push(ScanSegment {
1570 label: format!(
1571 "repo:{}",
1572 truncate_display(&display_or_fallback(&issue.source_repo, "local"), 10)
1573 ),
1574 kind: ScanSegmentKind::Chip(SemanticTone::Muted),
1575 });
1576 suffix.push(ScanSegment {
1577 label: format!("pr#{}", context.pagerank_rank),
1578 kind: ScanSegmentKind::Chip(SemanticTone::Neutral),
1579 });
1580 suffix.push(ScanSegment {
1581 label: format!("d{}", context.critical_depth),
1582 kind: ScanSegmentKind::Chip(if context.critical_depth > 0 {
1583 SemanticTone::Warning
1584 } else {
1585 SemanticTone::Muted
1586 }),
1587 });
1588 if let Some(label_summary) = issue_label_summary(issue) {
1589 suffix.push(ScanSegment {
1590 label: label_summary,
1591 kind: ScanSegmentKind::Chip(SemanticTone::Accent),
1592 });
1593 }
1594 }
1595 if let Some(position) = context.search_match_position {
1596 suffix.push(ScanSegment {
1597 label: if is_selected {
1598 format!("hit {position}/{}", context.total_search_matches)
1599 } else {
1600 "hit".to_string()
1601 },
1602 kind: ScanSegmentKind::Chip(if is_selected {
1603 SemanticTone::Warning
1604 } else {
1605 SemanticTone::Accent
1606 }),
1607 });
1608 }
1609
1610 if let Some(tag) = context.diff_tag {
1612 let (label, tone) = match tag {
1613 DiffTag::New => ("NEW", SemanticTone::Success),
1614 DiffTag::Modified => ("MOD", SemanticTone::Warning),
1615 DiffTag::Closed => ("CLO", SemanticTone::Muted),
1616 DiffTag::Reopened => ("RE!", SemanticTone::Danger),
1617 };
1618 suffix.push(ScanSegment {
1619 label: label.to_string(),
1620 kind: ScanSegmentKind::Chip(tone),
1621 });
1622 }
1623
1624 let min_title_width = match variant {
1625 ScanLineVariant::Narrow => 10,
1626 ScanLineVariant::Medium => 16,
1627 ScanLineVariant::Wide => 22,
1628 };
1629 while !suffix.is_empty()
1630 && context.available_width
1631 < scan_segments_width(&prefix)
1632 + scan_segments_width(&suffix)
1633 + min_title_width
1634 + usize::from(!prefix.is_empty())
1635 + usize::from(!suffix.is_empty())
1636 {
1637 suffix.pop();
1638 }
1639
1640 let reserved_width = scan_segments_width(&prefix)
1641 + scan_segments_width(&suffix)
1642 + usize::from(!prefix.is_empty())
1643 + usize::from(!suffix.is_empty());
1644 let title_width = context
1645 .available_width
1646 .saturating_sub(reserved_width)
1647 .max(min_title_width);
1648 let title = truncate_display(&issue.title, title_width);
1649
1650 let mut line = RichLine::new();
1651 let sep = if is_selected {
1652 RichSpan::styled(" ", tokens::selected())
1653 } else {
1654 RichSpan::raw(" ")
1655 };
1656 for (index, segment) in prefix.iter().enumerate() {
1657 if index > 0 {
1658 line.push_span(sep.clone());
1659 }
1660 push_scan_segment(&mut line, segment, is_selected);
1661 }
1662 if !prefix.is_empty() {
1663 line.push_span(sep.clone());
1664 }
1665 push_scan_segment(
1666 &mut line,
1667 &ScanSegment {
1668 label: title,
1669 kind: ScanSegmentKind::Title {
1670 selected: is_selected,
1671 },
1672 },
1673 is_selected,
1674 );
1675 if !suffix.is_empty() {
1676 line.push_span(sep.clone());
1677 }
1678 for (index, segment) in suffix.iter().enumerate() {
1679 if index > 0 {
1680 line.push_span(sep.clone());
1681 }
1682 push_scan_segment(&mut line, segment, is_selected);
1683 }
1684
1685 line
1686}
1687
1688#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1689pub enum ViewMode {
1690 Main,
1691 Board,
1692 Insights,
1693 Graph,
1694 History,
1695 Actionable,
1696 Attention,
1697 Tree,
1698 LabelDashboard,
1699 FlowMatrix,
1700 TimeTravelDiff,
1701 Sprint,
1702}
1703
1704impl ViewMode {
1705 pub fn from_cli(s: &str) -> Option<Self> {
1707 match s {
1708 "main" => Some(Self::Main),
1709 "board" => Some(Self::Board),
1710 "insights" => Some(Self::Insights),
1711 "graph" => Some(Self::Graph),
1712 "history" => Some(Self::History),
1713 "actionable" => Some(Self::Actionable),
1714 "attention" => Some(Self::Attention),
1715 "tree" => Some(Self::Tree),
1716 "labels" => Some(Self::LabelDashboard),
1717 "flow" => Some(Self::FlowMatrix),
1718 "timediff" => Some(Self::TimeTravelDiff),
1719 "sprint" => Some(Self::Sprint),
1720 _ => None,
1721 }
1722 }
1723}
1724
1725impl ViewMode {
1726 const fn navigation_order() -> [Self; 12] {
1727 [
1728 Self::Main,
1729 Self::Board,
1730 Self::Insights,
1731 Self::Graph,
1732 Self::History,
1733 Self::Actionable,
1734 Self::LabelDashboard,
1735 Self::FlowMatrix,
1736 Self::Attention,
1737 Self::Tree,
1738 Self::TimeTravelDiff,
1739 Self::Sprint,
1740 ]
1741 }
1742
1743 fn label(self) -> &'static str {
1744 match self {
1745 Self::Main => "Main",
1746 Self::Board => "Board",
1747 Self::Insights => "Insights",
1748 Self::Graph => "Graph",
1749 Self::History => "History",
1750 Self::Actionable => "Actionable",
1751 Self::Attention => "Attention",
1752 Self::Tree => "Tree",
1753 Self::LabelDashboard => "Labels",
1754 Self::FlowMatrix => "Flow",
1755 Self::TimeTravelDiff => "TimeTravel",
1756 Self::Sprint => "Sprint",
1757 }
1758 }
1759
1760 const fn shortcut(self) -> &'static str {
1761 match self {
1762 Self::Main => "1",
1763 Self::Board => "b",
1764 Self::Insights => "i",
1765 Self::Graph => "g",
1766 Self::History => "h",
1767 Self::Actionable => "a",
1768 Self::Attention => "!",
1769 Self::Tree => "T",
1770 Self::LabelDashboard => "[",
1771 Self::FlowMatrix => "]",
1772 Self::TimeTravelDiff => "t",
1773 Self::Sprint => "S",
1774 }
1775 }
1776
1777 const fn short_label(self) -> &'static str {
1778 match self {
1779 Self::Main => "Main",
1780 Self::Board => "Board",
1781 Self::Insights => "In",
1782 Self::Graph => "Graph",
1783 Self::History => "Hist",
1784 Self::Actionable => "Act",
1785 Self::Attention => "Attn",
1786 Self::Tree => "Tree",
1787 Self::LabelDashboard => "Lbl",
1788 Self::FlowMatrix => "Flow",
1789 Self::TimeTravelDiff => "Diff",
1790 Self::Sprint => "Sprint",
1791 }
1792 }
1793
1794 fn tab_text(self, bp: Breakpoint) -> String {
1795 let label = if matches!(bp, Breakpoint::Narrow) {
1796 self.short_label()
1797 } else {
1798 self.label()
1799 };
1800 format!("{} {label}", self.shortcut())
1801 }
1802}
1803
1804#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1805enum FocusPane {
1806 List,
1807 Middle,
1808 Detail,
1809}
1810
1811impl FocusPane {
1812 fn label(self) -> &'static str {
1813 match self {
1814 Self::List => "list",
1815 Self::Middle => "middle",
1816 Self::Detail => "detail",
1817 }
1818 }
1819}
1820
1821#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1822pub enum ListFilter {
1823 All,
1824 Open,
1825 InProgress,
1826 Blocked,
1827 Closed,
1828 Ready,
1829}
1830
1831impl ListFilter {
1832 fn label(self) -> &'static str {
1833 match self {
1834 Self::All => "all",
1835 Self::Open => "open",
1836 Self::InProgress => "in-progress",
1837 Self::Blocked => "blocked",
1838 Self::Closed => "closed",
1839 Self::Ready => "ready",
1840 }
1841 }
1842
1843 pub fn from_cli(s: &str) -> Option<Self> {
1845 match s {
1846 "all" => Some(Self::All),
1847 "open" => Some(Self::Open),
1848 "in-progress" | "in_progress" | "inprogress" => Some(Self::InProgress),
1849 "blocked" => Some(Self::Blocked),
1850 "closed" => Some(Self::Closed),
1851 "ready" => Some(Self::Ready),
1852 _ => None,
1853 }
1854 }
1855}
1856
1857#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1858enum ListSort {
1859 Default,
1860 CreatedAsc,
1861 CreatedDesc,
1862 Priority,
1863 Updated,
1864 PageRank,
1865 Blockers,
1866}
1867
1868impl ListSort {
1869 fn label(self) -> &'static str {
1870 match self {
1871 Self::Default => "default",
1872 Self::CreatedAsc => "created-asc",
1873 Self::CreatedDesc => "created-desc",
1874 Self::Priority => "priority",
1875 Self::Updated => "updated",
1876 Self::PageRank => "pagerank",
1877 Self::Blockers => "blockers",
1878 }
1879 }
1880
1881 fn next(self) -> Self {
1882 match self {
1883 Self::Default => Self::CreatedAsc,
1884 Self::CreatedAsc => Self::CreatedDesc,
1885 Self::CreatedDesc => Self::Priority,
1886 Self::Priority => Self::Updated,
1887 Self::Updated => Self::PageRank,
1888 Self::PageRank => Self::Blockers,
1889 Self::Blockers => Self::Default,
1890 }
1891 }
1892}
1893
1894#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1895enum BoardGrouping {
1896 Status,
1897 Priority,
1898 Type,
1899}
1900
1901impl BoardGrouping {
1902 fn label(self) -> &'static str {
1903 match self {
1904 Self::Status => "status",
1905 Self::Priority => "priority",
1906 Self::Type => "type",
1907 }
1908 }
1909
1910 fn next(self) -> Self {
1911 match self {
1912 Self::Status => Self::Priority,
1913 Self::Priority => Self::Type,
1914 Self::Type => Self::Status,
1915 }
1916 }
1917}
1918
1919#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1922enum EmptyLaneVisibility {
1923 Auto,
1924 ShowAll,
1925 HideEmpty,
1926}
1927
1928impl EmptyLaneVisibility {
1929 fn next(self) -> Self {
1930 match self {
1931 Self::Auto => Self::ShowAll,
1932 Self::ShowAll => Self::HideEmpty,
1933 Self::HideEmpty => Self::Auto,
1934 }
1935 }
1936
1937 fn label(self) -> &'static str {
1938 match self {
1939 Self::Auto => "Auto",
1940 Self::ShowAll => "Show All",
1941 Self::HideEmpty => "Hide Empty",
1942 }
1943 }
1944
1945 fn should_show_empty(self, grouping: BoardGrouping) -> bool {
1946 match self {
1947 Self::ShowAll => true,
1948 Self::HideEmpty => false,
1949 Self::Auto => matches!(grouping, BoardGrouping::Status),
1950 }
1951 }
1952}
1953
1954#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1955enum HistoryViewMode {
1956 Bead,
1957 Git,
1958}
1959
1960impl HistoryViewMode {
1961 fn label(self) -> &'static str {
1962 match self {
1963 Self::Bead => "bead",
1964 Self::Git => "git",
1965 }
1966 }
1967
1968 fn indicator(self) -> &'static str {
1969 match self {
1970 Self::Bead => "◈ Beads",
1971 Self::Git => "◉ Git",
1972 }
1973 }
1974
1975 fn toggle(self) -> Self {
1976 match self {
1977 Self::Bead => Self::Git,
1978 Self::Git => Self::Bead,
1979 }
1980 }
1981}
1982
1983#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1984enum HistoryLayout {
1985 Narrow,
1986 Standard,
1987 Wide,
1988}
1989
1990impl HistoryLayout {
1991 fn from_width(width: u16) -> Self {
1992 if width < 100 {
1993 Self::Narrow
1994 } else if width < 150 {
1995 Self::Standard
1996 } else {
1997 Self::Wide
1998 }
1999 }
2000
2001 fn has_middle_pane(self) -> bool {
2002 !matches!(self, Self::Narrow)
2003 }
2004}
2005
2006#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2008enum HistorySearchMode {
2009 #[default]
2011 All,
2012 Commit,
2014 Sha,
2016 Bead,
2018 Author,
2020}
2021
2022impl HistorySearchMode {
2023 fn label(self) -> &'static str {
2024 match self {
2025 Self::All => "all",
2026 Self::Commit => "msg",
2027 Self::Sha => "sha",
2028 Self::Bead => "bead",
2029 Self::Author => "author",
2030 }
2031 }
2032
2033 fn cycle(self) -> Self {
2034 match self {
2035 Self::All => Self::Commit,
2036 Self::Commit => Self::Sha,
2037 Self::Sha => Self::Bead,
2038 Self::Bead => Self::Author,
2039 Self::Author => Self::All,
2040 }
2041 }
2042}
2043
2044#[derive(Debug, Clone)]
2046struct FileTreeNode {
2047 name: String,
2048 path: String,
2049 is_dir: bool,
2050 change_count: usize,
2051 expanded: bool,
2052 level: usize,
2053 children: Vec<Self>,
2054}
2055
2056impl FileTreeNode {
2057 fn flatten_visible(&self) -> Vec<FlatFileEntry> {
2059 let mut out = Vec::new();
2060 self.flatten_into(&mut out);
2061 out
2062 }
2063
2064 fn flatten_into(&self, out: &mut Vec<FlatFileEntry>) {
2065 out.push(FlatFileEntry {
2066 name: self.name.clone(),
2067 path: self.path.clone(),
2068 is_dir: self.is_dir,
2069 change_count: self.change_count,
2070 level: self.level,
2071 });
2072 if self.is_dir && self.expanded {
2073 for child in &self.children {
2074 child.flatten_into(out);
2075 }
2076 }
2077 }
2078}
2079
2080#[derive(Debug, Clone)]
2082struct FlatFileEntry {
2083 name: String,
2084 path: String,
2085 is_dir: bool,
2086 change_count: usize,
2087 level: usize,
2088}
2089
2090#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2091enum InsightsPanel {
2092 Bottlenecks,
2093 Keystones,
2094 CriticalPath,
2095 Influencers,
2096 Betweenness,
2097 Hubs,
2098 Authorities,
2099 Cores,
2100 CutPoints,
2101 Slack,
2102 Cycles,
2103 Priority,
2104}
2105
2106impl InsightsPanel {
2107 fn label(self) -> &'static str {
2108 match self {
2109 Self::Bottlenecks => "Bottlenecks",
2110 Self::Keystones => "Keystones",
2111 Self::CriticalPath => "Critical Path",
2112 Self::Influencers => "Influencers (PageRank)",
2113 Self::Betweenness => "Betweenness",
2114 Self::Hubs => "Hubs (HITS)",
2115 Self::Authorities => "Authorities (HITS)",
2116 Self::Cores => "K-Core Cohesion",
2117 Self::CutPoints => "Cut Points",
2118 Self::Slack => "Slack (Zero)",
2119 Self::Cycles => "Cycles",
2120 Self::Priority => "Priority",
2121 }
2122 }
2123
2124 fn short_label(self) -> &'static str {
2125 match self {
2126 Self::Bottlenecks => "bottlenecks",
2127 Self::Keystones => "keystones",
2128 Self::CriticalPath => "crit-path",
2129 Self::Influencers => "influencers",
2130 Self::Betweenness => "betweenness",
2131 Self::Hubs => "hubs",
2132 Self::Authorities => "authorities",
2133 Self::Cores => "k-core",
2134 Self::CutPoints => "cut-pts",
2135 Self::Slack => "slack",
2136 Self::Cycles => "cycles",
2137 Self::Priority => "priority",
2138 }
2139 }
2140
2141 fn next(self) -> Self {
2142 match self {
2143 Self::Bottlenecks => Self::Keystones,
2144 Self::Keystones => Self::CriticalPath,
2145 Self::CriticalPath => Self::Influencers,
2146 Self::Influencers => Self::Betweenness,
2147 Self::Betweenness => Self::Hubs,
2148 Self::Hubs => Self::Authorities,
2149 Self::Authorities => Self::Cores,
2150 Self::Cores => Self::CutPoints,
2151 Self::CutPoints => Self::Slack,
2152 Self::Slack => Self::Cycles,
2153 Self::Cycles => Self::Priority,
2154 Self::Priority => Self::Bottlenecks,
2155 }
2156 }
2157
2158 fn prev(self) -> Self {
2159 match self {
2160 Self::Bottlenecks => Self::Priority,
2161 Self::Keystones => Self::Bottlenecks,
2162 Self::CriticalPath => Self::Keystones,
2163 Self::Influencers => Self::CriticalPath,
2164 Self::Betweenness => Self::Influencers,
2165 Self::Hubs => Self::Betweenness,
2166 Self::Authorities => Self::Hubs,
2167 Self::Cores => Self::Authorities,
2168 Self::CutPoints => Self::Cores,
2169 Self::Slack => Self::CutPoints,
2170 Self::Cycles => Self::Slack,
2171 Self::Priority => Self::Cycles,
2172 }
2173 }
2174}
2175
2176const INSIGHTS_HEATMAP_DEPTH_LABELS: [&str; 5] = ["D=0", "D1-2", "D3-5", "D6-10", "D10+"];
2177const INSIGHTS_HEATMAP_SCORE_LABELS: [&str; 5] = ["0-.2", ".2-.4", ".4-.6", ".6-.8", ".8-1"];
2178
2179#[derive(Debug, Clone, Default)]
2180struct InsightsHeatmapState {
2181 row: usize,
2182 col: usize,
2183 drill_active: bool,
2184 drill_cursor: usize,
2185}
2186
2187#[derive(Debug, Clone)]
2188struct InsightsHeatmapData {
2189 counts: Vec<Vec<usize>>,
2190 issue_ids: Vec<Vec<Vec<String>>>,
2191}
2192
2193const HISTORY_CONFIDENCE_STEPS: [f64; 4] = [0.0, 0.5, 0.75, 0.9];
2194
2195#[derive(Debug, Clone)]
2196struct HistoryGitCache {
2197 commits: Vec<GitCommitRecord>,
2198 histories: BTreeMap<String, HistoryBeadCompat>,
2199 commit_bead_confidence: BTreeMap<String, Vec<(String, f64)>>,
2200}
2201
2202#[derive(Debug, Clone, PartialEq, Eq)]
2208enum ModalOverlay {
2209 Tutorial,
2211 Confirm {
2213 title: String,
2214 message: String,
2215 resume_overlay: Option<Box<ModalOverlay>>,
2216 },
2217 PagesWizard(PagesWizardState),
2219 RecipePicker {
2221 items: Vec<(String, String)>,
2222 cursor: usize,
2223 },
2224 LabelPicker {
2226 items: Vec<(String, usize)>,
2227 cursor: usize,
2228 filter: String,
2229 },
2230 RepoPicker {
2232 items: Vec<String>,
2233 cursor: usize,
2234 filter: String,
2235 },
2236}
2237
2238#[derive(Debug, Clone, PartialEq, Eq)]
2240struct PagesWizardState {
2241 step: usize,
2242 export_dir: String,
2243 title: String,
2244 include_closed: bool,
2245 include_history: bool,
2246}
2247
2248impl PagesWizardState {
2249 fn new() -> Self {
2250 Self {
2251 step: 0,
2252 export_dir: "./bv-pages".to_string(),
2253 title: String::new(),
2254 include_closed: true,
2255 include_history: true,
2256 }
2257 }
2258
2259 fn step_count() -> usize {
2260 4
2261 }
2262
2263 fn step_label(&self) -> &'static str {
2264 match self.step {
2265 0 => "Export Directory",
2266 1 => "Page Title",
2267 2 => "Options",
2268 3 => "Review & Export",
2269 _ => "Done",
2270 }
2271 }
2272}
2273
2274#[derive(Debug, Clone)]
2275struct HistoryTimelineEvent {
2276 issue_id: String,
2277 issue_title: String,
2278 issue_status: String,
2279 event_kind: String,
2280 event_timestamp: Option<DateTime<Utc>>,
2281 event_details: String,
2282}
2283
2284#[derive(Debug, Clone)]
2286struct TreeFlatNode {
2287 issue_index: usize,
2289 depth: usize,
2291 has_children: bool,
2293 is_collapsed: bool,
2295 is_last_sibling: bool,
2297 ancestry_last: Vec<bool>,
2299}
2300
2301#[derive(Debug)]
2302enum Msg {
2303 KeyPress(KeyCode, Modifiers),
2304 Mouse(MouseEvent),
2305 #[cfg(not(test))]
2306 Tick,
2307 #[cfg(not(test))]
2308 BackgroundReloaded(std::result::Result<Vec<Issue>, String>),
2309 Noop,
2310}
2311
2312impl From<Event> for Msg {
2313 fn from(event: Event) -> Self {
2314 match event {
2315 Event::Key(KeyEvent {
2316 code,
2317 modifiers,
2318 kind: KeyEventKind::Press,
2319 ..
2320 }) => Self::KeyPress(code, modifiers),
2321 Event::Mouse(event) => Self::Mouse(event),
2322 #[cfg(not(test))]
2323 Event::Tick => Self::Tick,
2324 _ => Self::Noop,
2325 }
2326 }
2327}
2328
2329#[derive(Debug)]
2330struct BvrApp {
2331 analyzer: Analyzer,
2332 repo_root: Option<PathBuf>,
2333 selected: usize,
2334 list_filter: ListFilter,
2335 list_sort: ListSort,
2336 board_grouping: BoardGrouping,
2337 board_empty_visibility: EmptyLaneVisibility,
2338 mode: ViewMode,
2339 mode_before_history: ViewMode,
2340 mode_back_stack: Vec<ViewMode>,
2342 focus: FocusPane,
2343 focus_before_help: FocusPane,
2344 show_help: bool,
2345 help_scroll_offset: usize,
2346 show_quit_confirm: bool,
2347 modal_overlay: Option<ModalOverlay>,
2348 modal_confirm_result: Option<bool>,
2349 history_confidence_index: usize,
2350 history_view_mode: HistoryViewMode,
2351 history_event_cursor: usize,
2352 history_related_bead_cursor: usize,
2353 history_bead_commit_cursor: usize,
2354 history_git_cache: Option<HistoryGitCache>,
2355 history_search_active: bool,
2356 history_search_query: String,
2357 history_search_match_cursor: usize,
2358 history_search_mode: HistorySearchMode,
2359 history_show_file_tree: bool,
2360 history_file_tree_cursor: usize,
2361 history_file_tree_filter: Option<String>,
2362 history_file_tree_focus: bool,
2363 history_status_msg: String,
2364 board_search_active: bool,
2365 board_search_query: String,
2366 board_search_match_cursor: usize,
2367 board_detail_scroll_offset: usize,
2368 detail_scroll_offset: usize,
2370 main_search_active: bool,
2371 main_search_query: String,
2372 main_search_match_cursor: usize,
2373 list_scroll_offset: Cell<usize>,
2374 list_viewport_height: Cell<usize>,
2375 graph_search_active: bool,
2376 graph_search_query: String,
2377 graph_search_match_cursor: usize,
2378 insights_search_active: bool,
2379 insights_search_query: String,
2380 insights_search_match_cursor: usize,
2381 insights_panel: InsightsPanel,
2382 insights_heatmap: Option<InsightsHeatmapState>,
2383 insights_show_explanations: bool,
2384 insights_show_calc_proof: bool,
2385 detail_dep_cursor: usize,
2386 actionable_plan: Option<crate::analysis::plan::ExecutionPlan>,
2387 actionable_track_cursor: usize,
2388 actionable_item_cursor: usize,
2389 attention_result: Option<crate::analysis::label_intel::LabelAttentionResult>,
2390 attention_cursor: usize,
2391 tree_flat_nodes: Vec<TreeFlatNode>,
2392 tree_cursor: usize,
2393 tree_collapsed: std::collections::HashSet<String>,
2394 tree_search_active: bool,
2395 tree_search_query: String,
2396 tree_search_match_cursor: usize,
2397 pending_g: bool,
2399 g_pre_toggle_mode: Option<ViewMode>,
2401 pending_z: bool,
2403 label_dashboard: Option<crate::analysis::label_intel::LabelHealthResult>,
2404 label_dashboard_cursor: usize,
2405 flow_matrix: Option<crate::analysis::label_intel::CrossLabelFlow>,
2406 flow_matrix_row_cursor: usize,
2407 flow_matrix_col_cursor: usize,
2408 sprint_data: Vec<Sprint>,
2409 sprint_cursor: usize,
2410 sprint_issue_cursor: usize,
2411 modal_label_filter: Option<String>,
2412 modal_repo_filter: Option<String>,
2413 time_travel_ref_input: String,
2414 time_travel_input_active: bool,
2415 time_travel_diff: Option<crate::analysis::diff::SnapshotDiff>,
2416 time_travel_category_cursor: usize,
2417 time_travel_issue_cursor: usize,
2418 time_travel_last_ref: Option<String>,
2419 priority_hints_visible: bool,
2420 status_msg: String,
2421 slow_metrics_pending: bool,
2422 #[cfg(not(test))]
2423 slow_metrics_rx: Option<std::sync::mpsc::Receiver<crate::analysis::graph::GraphMetrics>>,
2424 #[cfg(not(test))]
2425 background_runtime: Option<BackgroundRuntimeState>,
2426 #[cfg(test)]
2429 key_trace: Vec<KeyTraceEntry>,
2430}
2431
2432#[cfg(test)]
2434#[derive(Debug, Clone)]
2435#[allow(dead_code)]
2436struct KeyTraceEntry {
2437 key: String,
2438 mode: ViewMode,
2439 focus: FocusPane,
2440 selected: usize,
2441 filter: ListFilter,
2442}
2443
2444impl Model for BvrApp {
2445 type Message = Msg;
2446
2447 fn init(&mut self) -> Cmd<Self::Message> {
2448 #[cfg(not(test))]
2449 {
2450 self.background_tick_command()
2451 }
2452 #[cfg(test)]
2453 {
2454 Cmd::None
2455 }
2456 }
2457
2458 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
2459 match msg {
2460 Msg::KeyPress(code, modifiers) => {
2461 let mode_before = self.mode;
2462 let cmd = self.handle_key(code, modifiers);
2463 if self.mode != mode_before {
2464 self.list_scroll_offset.set(0);
2465 }
2466 #[cfg(test)]
2467 self.key_trace.push(KeyTraceEntry {
2468 key: format!("{code:?}"),
2469 mode: self.mode,
2470 focus: self.focus,
2471 selected: self.selected,
2472 filter: self.list_filter,
2473 });
2474 #[cfg(not(test))]
2475 {
2476 return self.wrap_quit_with_background_cancel(cmd);
2477 }
2478 #[cfg(test)]
2479 {
2480 return cmd;
2481 }
2482 }
2483 Msg::Mouse(event) => return self.handle_mouse(event),
2484 #[cfg(not(test))]
2485 Msg::Tick => return self.handle_background_tick(),
2486 #[cfg(not(test))]
2487 Msg::BackgroundReloaded(result) => {
2488 self.handle_background_reload_result(result);
2489 return Cmd::None;
2490 }
2491 Msg::Noop => {}
2492 }
2493
2494 Cmd::None
2495 }
2496
2497 fn view(&self, frame: &mut Frame) {
2498 let full = Rect::from_size(frame.buffer.width(), frame.buffer.height());
2499 record_view_size(full.width, full.height);
2500 record_detail_content_area(Rect::default());
2501 let bp = Breakpoint::from_width(full.width);
2502
2503 let rows = Flex::vertical()
2504 .constraints([
2505 Constraint::Fixed(1),
2506 Constraint::Min(3),
2507 Constraint::Fixed(1),
2508 ])
2509 .split(full);
2510
2511 let header_text = build_header_text(self, full.width);
2513 Paragraph::new(header_text)
2514 .style(tokens::header_bg())
2515 .render(rows[0], frame);
2516
2517 if self.show_help {
2519 let inner_width = rows[1].width.saturating_sub(2) as usize;
2520 let full_help = self.help_overlay_text(inner_width);
2521 let help_lines: Vec<&str> = full_help.lines().collect();
2522 let visible_height = rows[1].height.saturating_sub(2) as usize; let max_offset = help_lines.len().saturating_sub(visible_height);
2524 let offset = self.help_scroll_offset.min(max_offset);
2525 let visible: String = help_lines
2526 .iter()
2527 .skip(offset)
2528 .take(visible_height)
2529 .copied()
2530 .collect::<Vec<&str>>()
2531 .join("\n");
2532 Paragraph::new(visible)
2533 .block(semantic_panel_block("Help", true, SemanticTone::Accent))
2534 .render(rows[1], frame);
2535 let scroll_hint = if help_lines.len() > visible_height {
2536 format!(
2537 "? or Esc close | j/k scroll | Ctrl+d/u page | line {}/{}",
2538 offset + 1,
2539 help_lines.len()
2540 )
2541 } else {
2542 "? or Esc to close help".to_string()
2543 };
2544 Paragraph::new(scroll_hint)
2545 .style(tokens::footer())
2546 .render(rows[2], frame);
2547 return;
2548 }
2549
2550 if self.show_quit_confirm {
2552 Paragraph::new("Quit bvr?\n\nPress Esc or Y to quit.\nPress any other key to cancel.")
2553 .block(semantic_panel_block(
2554 "Confirm Quit",
2555 false,
2556 SemanticTone::Danger,
2557 ))
2558 .render(rows[1], frame);
2559 Paragraph::new("Esc/Y confirms quit. Any other key cancels.")
2560 .style(tokens::footer())
2561 .render(rows[2], frame);
2562 return;
2563 }
2564
2565 if let Some(ref overlay) = self.modal_overlay {
2567 match overlay {
2568 ModalOverlay::Tutorial => {
2569 let text = concat!(
2570 "Welcome to bvr!\n\n",
2571 "Modes: b=board i=insights g=graph h=history\n",
2572 "Filter: o=open c=closed r=ready a=all\n",
2573 "Nav: j/k=up/down Tab=focus /=search n/N=cycle\n",
2574 "Other: ?=help s=sort Enter=select Esc=back q=quit\n\n",
2575 "Press any key to dismiss."
2576 );
2577 Paragraph::new(text)
2578 .block(semantic_panel_block("Tutorial", true, SemanticTone::Accent))
2579 .render(rows[1], frame);
2580 Paragraph::new("Press any key to continue.")
2581 .style(tokens::footer())
2582 .render(rows[2], frame);
2583 return;
2584 }
2585 ModalOverlay::Confirm { title, message, .. } => {
2586 let text = format!("{message}\n\nPress Y to confirm, N or Esc to cancel.");
2587 Paragraph::new(text)
2588 .block(semantic_panel_block(
2589 title.as_str(),
2590 false,
2591 SemanticTone::Danger,
2592 ))
2593 .render(rows[1], frame);
2594 Paragraph::new("Y=confirm | N/Esc=cancel")
2595 .style(tokens::footer())
2596 .render(rows[2], frame);
2597 return;
2598 }
2599 ModalOverlay::PagesWizard(wiz) => {
2600 let text = Self::pages_wizard_text(wiz);
2601 let wiz_title = format!(
2602 "Pages Wizard ({}/{}): {}",
2603 wiz.step + 1,
2604 PagesWizardState::step_count(),
2605 wiz.step_label()
2606 );
2607 Paragraph::new(text)
2608 .block(semantic_panel_block(
2609 wiz_title.as_str(),
2610 true,
2611 SemanticTone::Accent,
2612 ))
2613 .render(rows[1], frame);
2614 let footer = if wiz.step == PagesWizardState::step_count() - 1 {
2615 "Enter=export | Esc=cancel | Backspace=prev step"
2616 } else {
2617 "Enter=next step | Esc=cancel | Backspace=prev step"
2618 };
2619 Paragraph::new(footer)
2620 .style(tokens::footer())
2621 .render(rows[2], frame);
2622 return;
2623 }
2624 ModalOverlay::RecipePicker { items, cursor } => {
2625 let mut lines = Vec::new();
2626 for (i, (name, desc)) in items.iter().enumerate() {
2627 let marker = if i == *cursor { "▸" } else { " " };
2628 lines.push(format!(" {marker} {name:16} {desc}"));
2629 }
2630 let text = if lines.is_empty() {
2631 " No recipes available.".to_string()
2632 } else {
2633 lines.join("\n")
2634 };
2635 Paragraph::new(text)
2636 .block(semantic_panel_block(
2637 "Recipe Picker",
2638 true,
2639 SemanticTone::Accent,
2640 ))
2641 .render(rows[1], frame);
2642 Paragraph::new("j/k=navigate | Enter=apply | Esc=close")
2643 .style(tokens::footer())
2644 .render(rows[2], frame);
2645 return;
2646 }
2647 ModalOverlay::LabelPicker {
2648 items,
2649 cursor,
2650 filter,
2651 } => {
2652 let needle = filter.to_ascii_lowercase();
2653 let mut lines = Vec::new();
2654 if !filter.is_empty() {
2655 lines.push(format!(" Filter: /{filter}"));
2656 }
2657 let mut vis_idx = 0usize;
2658 for (label, count) in items {
2659 if !needle.is_empty() && !label.to_ascii_lowercase().contains(&needle) {
2660 continue;
2661 }
2662 let marker = if vis_idx == *cursor { "▸" } else { " " };
2663 lines.push(format!(" {marker} {label:24} ({count} issues)"));
2664 vis_idx += 1;
2665 }
2666 let text = if vis_idx == 0 {
2667 if filter.is_empty() {
2668 " No labels found.".to_string()
2669 } else {
2670 format!(" No labels match: /{filter}")
2671 }
2672 } else {
2673 lines.join("\n")
2674 };
2675 Paragraph::new(text)
2676 .block(semantic_panel_block(
2677 "Label Picker",
2678 true,
2679 SemanticTone::Accent,
2680 ))
2681 .render(rows[1], frame);
2682 Paragraph::new("Type to filter | ↑/↓=navigate | Enter=apply | Esc=close")
2683 .style(tokens::footer())
2684 .render(rows[2], frame);
2685 return;
2686 }
2687 ModalOverlay::RepoPicker {
2688 items,
2689 cursor,
2690 filter,
2691 } => {
2692 let needle = filter.to_ascii_lowercase();
2693 let mut lines = Vec::new();
2694 if !filter.is_empty() {
2695 lines.push(format!(" Filter: /{filter}"));
2696 }
2697 let mut vis_idx = 0usize;
2698 for repo in items {
2699 if !needle.is_empty() && !repo.to_ascii_lowercase().contains(&needle) {
2700 continue;
2701 }
2702 let marker = if vis_idx == *cursor { "▸" } else { " " };
2703 lines.push(format!(" {marker} {repo}"));
2704 vis_idx += 1;
2705 }
2706 let text = if vis_idx == 0 {
2707 if filter.is_empty() {
2708 " No repos found.".to_string()
2709 } else {
2710 format!(" No repos match: /{filter}")
2711 }
2712 } else {
2713 lines.join("\n")
2714 };
2715 Paragraph::new(text)
2716 .block(semantic_panel_block(
2717 "Repo Picker",
2718 true,
2719 SemanticTone::Accent,
2720 ))
2721 .render(rows[1], frame);
2722 Paragraph::new("Type to filter | ↑/↓=navigate | Enter=apply | Esc=close")
2723 .style(tokens::footer())
2724 .render(rows[2], frame);
2725 return;
2726 }
2727 }
2728 }
2729
2730 let body = rows[1];
2732 let graph_single_pane =
2733 matches!(self.mode, ViewMode::Graph) && matches!(bp, Breakpoint::Narrow);
2734 let history_layout = if matches!(self.mode, ViewMode::History) {
2735 HistoryLayout::from_width(body.width)
2736 } else {
2737 HistoryLayout::Narrow
2738 };
2739 let split_state = pane_split_state();
2740 let history_multi_pane =
2741 matches!(self.mode, ViewMode::History) && history_layout.has_middle_pane();
2742 let mut detail_viewport_height = body.height.saturating_sub(2) as usize;
2743 let mut board_detail_line = None;
2744
2745 let detail_title = match self.mode {
2746 ViewMode::Board => "Board Focus",
2747 ViewMode::Insights => "Insight Detail",
2748 ViewMode::Graph if graph_single_pane => "Graph View",
2749 ViewMode::Graph => "Graph Focus",
2750 ViewMode::History => self.history_detail_panel_title(),
2751 ViewMode::Actionable => "Track Detail",
2752 ViewMode::Attention => "Label Detail",
2753 ViewMode::Tree => "Issue Detail",
2754 ViewMode::LabelDashboard => "Label Detail",
2755 ViewMode::FlowMatrix => "Flow Detail",
2756 ViewMode::TimeTravelDiff => "Diff Detail",
2757 ViewMode::Sprint => "Sprint Detail",
2758 ViewMode::Main => "Details",
2759 };
2760 let detail_focused = self.focus == FocusPane::Detail
2761 || graph_single_pane
2762 || (matches!(self.mode, ViewMode::History) && self.history_file_tree_focus);
2763 let detail_title = if detail_focused {
2764 format!("{detail_title} [focus]")
2765 } else {
2766 detail_title.to_string()
2767 };
2768 if graph_single_pane {
2769 record_detail_content_area(block_inner_rect(body));
2770 Paragraph::new(self.graph_detail_render_text())
2771 .block(semantic_panel_block(
2772 &detail_title,
2773 detail_focused,
2774 SemanticTone::Accent,
2775 ))
2776 .render(body, frame);
2777 } else if history_multi_pane {
2778 let render_history_panel =
2779 |frame: &mut Frame, area: Rect, title: String, focused: bool, text: RichText| {
2780 Paragraph::new(text)
2781 .block(semantic_panel_block(
2782 title.as_str(),
2783 focused,
2784 SemanticTone::Accent,
2785 ))
2786 .render(area, frame);
2787 };
2788
2789 if matches!(history_layout, HistoryLayout::Wide)
2790 && matches!(self.history_view_mode, HistoryViewMode::Bead)
2791 {
2792 let PaneSplitPreset::Four(pcts) =
2793 split_state.history_pcts(history_layout, self.history_view_mode)
2794 else {
2795 unreachable!("wide bead history should use four-pane split");
2796 };
2797 let panes = Flex::horizontal()
2798 .constraints([
2799 Constraint::Percentage(pcts[0]),
2800 Constraint::Percentage(pcts[1]),
2801 Constraint::Percentage(pcts[2]),
2802 Constraint::Percentage(pcts[3]),
2803 ])
2804 .split(body);
2805
2806 render_history_panel(
2807 frame,
2808 panes[0],
2809 if matches!(self.focus, FocusPane::List) {
2810 format!("{} [focus]", self.history_list_panel_title())
2811 } else {
2812 self.history_list_panel_title().to_string()
2813 },
2814 matches!(self.focus, FocusPane::List),
2815 RichText::raw(self.history_list_text()),
2816 );
2817 render_history_panel(
2818 frame,
2819 panes[1],
2820 self.history_timeline_panel_title(),
2821 false,
2822 RichText::raw(self.history_timeline_text(panes[1].width, panes[1].height)),
2823 );
2824 render_history_panel(
2825 frame,
2826 panes[2],
2827 if matches!(self.focus, FocusPane::Middle) {
2828 format!("{} [focus]", self.history_middle_panel_title())
2829 } else {
2830 self.history_middle_panel_title().to_string()
2831 },
2832 matches!(self.focus, FocusPane::Middle),
2833 RichText::raw(self.history_middle_text(panes[2].width, panes[2].height)),
2834 );
2835 detail_viewport_height = panes[3].height.saturating_sub(2) as usize;
2836 render_history_panel(
2837 frame,
2838 panes[3],
2839 detail_title.clone(),
2840 detail_focused,
2841 self.detail_panel_render_text(),
2842 );
2843 record_detail_content_area(block_inner_rect(panes[3]));
2844 } else {
2845 let PaneSplitPreset::Three(pane_widths) =
2846 split_state.history_pcts(history_layout, self.history_view_mode)
2847 else {
2848 unreachable!("multi-pane history should use three-pane split");
2849 };
2850 let panes = Flex::horizontal()
2851 .constraints([
2852 Constraint::Percentage(pane_widths[0]),
2853 Constraint::Percentage(pane_widths[1]),
2854 Constraint::Percentage(pane_widths[2]),
2855 ])
2856 .split(body);
2857
2858 render_history_panel(
2859 frame,
2860 panes[0],
2861 if matches!(self.focus, FocusPane::List) {
2862 format!("{} [focus]", self.history_list_panel_title())
2863 } else {
2864 self.history_list_panel_title().to_string()
2865 },
2866 matches!(self.focus, FocusPane::List),
2867 RichText::raw(self.history_list_text()),
2868 );
2869 render_history_panel(
2870 frame,
2871 panes[1],
2872 if matches!(self.focus, FocusPane::Middle) {
2873 format!("{} [focus]", self.history_middle_panel_title())
2874 } else {
2875 self.history_middle_panel_title().to_string()
2876 },
2877 matches!(self.focus, FocusPane::Middle),
2878 RichText::raw(self.history_middle_text(panes[1].width, panes[1].height)),
2879 );
2880 detail_viewport_height = panes[2].height.saturating_sub(2) as usize;
2881 render_history_panel(
2882 frame,
2883 panes[2],
2884 detail_title.clone(),
2885 detail_focused,
2886 self.detail_panel_render_text(),
2887 );
2888 record_detail_content_area(block_inner_rect(panes[2]));
2889 }
2890 } else {
2891 let panes = Flex::horizontal()
2892 .constraints([
2893 Constraint::Percentage(split_state.two_pane_list_pct(bp)),
2894 Constraint::Percentage(split_state.two_pane_detail_pct(bp)),
2895 ])
2896 .split(body);
2897
2898 let list_text = self.list_panel_render_text(panes[0].width);
2899 let list_title = match self.mode {
2900 ViewMode::Board => "Board Lanes",
2901 ViewMode::Insights => "Insight Queue",
2902 ViewMode::Graph => "Graph Nodes",
2903 ViewMode::History => self.history_list_panel_title(),
2904 ViewMode::Actionable => "Execution Tracks",
2905 ViewMode::Attention => "Label Attention",
2906 ViewMode::Tree => "Dependency Tree",
2907 ViewMode::LabelDashboard => "Label Health",
2908 ViewMode::FlowMatrix => "Flow Matrix",
2909 ViewMode::TimeTravelDiff => "Diff Categories",
2910 ViewMode::Sprint => "Sprints",
2911 ViewMode::Main => "Issues",
2912 };
2913 let list_focused = self.focus == FocusPane::List;
2914 let list_title = if list_focused {
2915 format!("{list_title} [focus]")
2916 } else {
2917 list_title.to_string()
2918 };
2919
2920 let vp_height = panes[0].height.saturating_sub(2) as usize;
2921 self.list_viewport_height.set(vp_height);
2922 if vp_height > 0 {
2925 let scroll = self.list_scroll_offset.get();
2926 if let Some(cursor_line) = list_text.to_plain_text().lines().position(|line| {
2927 line.starts_with('>')
2928 || line.starts_with(" >")
2929 || line.starts_with(" >")
2930 || line.starts_with(" >")
2931 || line.starts_with(" >")
2932 || line.contains('\u{25b6}')
2933 || line.contains('▸')
2934 }) {
2935 if cursor_line < scroll {
2936 self.list_scroll_offset.set(cursor_line);
2937 } else if cursor_line >= scroll + vp_height {
2938 self.list_scroll_offset
2939 .set(cursor_line.saturating_sub(vp_height - 1));
2940 }
2941 }
2942 }
2943 Paragraph::new(list_text)
2944 .block(semantic_panel_block(
2945 &list_title,
2946 list_focused,
2947 SemanticTone::Accent,
2948 ))
2949 .scroll((saturating_scroll_offset(self.list_scroll_offset.get()), 0))
2950 .render(panes[0], frame);
2951
2952 detail_viewport_height = panes[1].height.saturating_sub(2) as usize;
2953 let detail_text = if matches!(self.mode, ViewMode::Board) {
2954 let rendered = self.board_detail_render_text();
2955 let total_lines = rendered.lines().len();
2956 let max_offset = total_lines.saturating_sub(detail_viewport_height);
2957 let offset = self.board_detail_scroll_offset.min(max_offset);
2958 board_detail_line = Some((offset, total_lines));
2959 rendered
2960 } else if matches!(self.mode, ViewMode::Graph) {
2961 self.graph_detail_render_text()
2962 } else {
2963 self.detail_panel_render_text()
2964 };
2965 let detail_scroll = if matches!(self.mode, ViewMode::Board) {
2966 board_detail_line.map_or(0, |(o, _)| o)
2968 } else {
2969 usize::from(saturating_scroll_offset(self.detail_scroll_offset))
2970 };
2971 Paragraph::new(detail_text)
2972 .block(semantic_panel_block(
2973 &detail_title,
2974 detail_focused,
2975 SemanticTone::Accent,
2976 ))
2977 .scroll((saturating_scroll_offset(detail_scroll), 0))
2978 .render(panes[1], frame);
2979 record_detail_content_area(block_inner_rect(panes[1]));
2980 }
2981
2982 let footer_text = match self.mode {
2984 ViewMode::Main => {
2985 if self.status_msg.is_empty() {
2986 None
2987 } else {
2988 Some(RichText::raw(self.status_msg.clone()))
2989 }
2990 }
2991 ViewMode::Board => {
2992 let detail_hint_desc = board_detail_line.map_or_else(
2993 || "detail scroll".to_string(),
2994 |(offset, total_lines)| {
2995 if total_lines > detail_viewport_height {
2996 format!("detail scroll ({}/{})", offset + 1, total_lines)
2997 } else {
2998 "detail scroll".to_string()
2999 }
3000 },
3001 );
3002 if self.status_msg.is_empty() {
3003 let grouping_label = self.board_grouping.label();
3004 let empty_label = self.board_empty_visibility.label();
3005 let mut hints = vec![
3006 CommandHint {
3007 key: "Tab",
3008 desc: "focus",
3009 },
3010 CommandHint {
3011 key: "/",
3012 desc: "search",
3013 },
3014 ];
3015 let grouping_desc = format!("grouping={grouping_label}");
3016 let empty_desc = format!("empty-lanes={empty_label}");
3017 hints.push(CommandHint {
3018 key: "s",
3019 desc: &grouping_desc,
3020 });
3021 hints.push(CommandHint {
3022 key: "e",
3023 desc: &empty_desc,
3024 });
3025 hints.push(CommandHint {
3026 key: "H/L",
3027 desc: "lanes",
3028 });
3029 hints.push(CommandHint {
3030 key: "0/$",
3031 desc: "edges",
3032 });
3033 hints.push(CommandHint {
3034 key: "^j/k",
3035 desc: &detail_hint_desc,
3036 });
3037 if self.should_open_selected_issue_external_ref() {
3038 hints.push(CommandHint {
3039 key: "o",
3040 desc: "open link",
3041 });
3042 hints.push(CommandHint {
3043 key: "y",
3044 desc: "copy link",
3045 });
3046 }
3047 Some(styled_mode_footer("Board", &hints, rows[2].width))
3048 } else {
3049 Some(RichText::raw(self.status_msg.clone()))
3050 }
3051 }
3052 ViewMode::Insights => {
3053 if self.status_msg.is_empty() {
3054 let panel_label = self.insights_panel.short_label();
3055 let expl_flag = if self.insights_show_explanations {
3056 "on"
3057 } else {
3058 "off"
3059 };
3060 let proof_flag = if self.insights_show_calc_proof {
3061 "on"
3062 } else {
3063 "off"
3064 };
3065 let expl_desc = format!("explanations={expl_flag}");
3066 let proof_desc = format!("proof={proof_flag}");
3067 let mut hints = vec![
3068 CommandHint {
3069 key: "Tab",
3070 desc: "focus",
3071 },
3072 CommandHint {
3073 key: "/",
3074 desc: "search",
3075 },
3076 CommandHint {
3077 key: "s/S",
3078 desc: "panel",
3079 },
3080 CommandHint {
3081 key: "e",
3082 desc: &expl_desc,
3083 },
3084 CommandHint {
3085 key: "x",
3086 desc: &proof_desc,
3087 },
3088 ];
3089 if self.should_open_selected_issue_external_ref() {
3090 hints.push(CommandHint {
3091 key: "o",
3092 desc: "open link",
3093 });
3094 hints.push(CommandHint {
3095 key: "y",
3096 desc: "copy link",
3097 });
3098 }
3099 Some(styled_mode_footer(
3100 &format!("Insights [{panel_label}]"),
3101 &hints,
3102 rows[2].width,
3103 ))
3104 } else {
3105 Some(RichText::raw(self.status_msg.clone()))
3106 }
3107 }
3108 ViewMode::Graph => {
3109 if self.status_msg.is_empty() {
3110 None
3111 } else {
3112 Some(RichText::raw(self.status_msg.clone()))
3113 }
3114 }
3115 ViewMode::History => {
3116 if self.history_search_active {
3117 let mode_label = self.history_view_mode.label();
3118 let search_label = self.history_search_mode.label();
3119 Some(styled_mode_footer(
3120 &format!("History ({mode_label}): [{search_label}]"),
3121 &[
3122 CommandHint {
3123 key: "Tab",
3124 desc: "cycles mode",
3125 },
3126 CommandHint {
3127 key: "Enter",
3128 desc: "confirm",
3129 },
3130 CommandHint {
3131 key: "Esc",
3132 desc: "cancel",
3133 },
3134 ],
3135 rows[2].width,
3136 ))
3137 } else {
3138 None
3139 }
3140 }
3141 ViewMode::Actionable => {
3142 let plan = self.actionable_plan.as_ref();
3143 let track_count = plan.map_or(0, |p| p.tracks.len());
3144 let item_count = plan.map_or(0, |p| p.summary.actionable_count);
3145 Some(styled_mode_footer(
3146 &format!("Actionable: {track_count} tracks, {item_count} items"),
3147 &[
3148 CommandHint {
3149 key: "j/k",
3150 desc: "navigate",
3151 },
3152 CommandHint {
3153 key: "Tab",
3154 desc: "focus",
3155 },
3156 CommandHint {
3157 key: "a/Esc",
3158 desc: "back",
3159 },
3160 ],
3161 rows[2].width,
3162 ))
3163 }
3164 ViewMode::Attention => {
3165 let label_count = self.attention_result.as_ref().map_or(0, |r| r.labels.len());
3166 Some(styled_mode_footer(
3167 &format!("Attention: {label_count} labels ranked"),
3168 &[
3169 CommandHint {
3170 key: "j/k",
3171 desc: "navigate",
3172 },
3173 CommandHint {
3174 key: "Tab",
3175 desc: "focus",
3176 },
3177 CommandHint {
3178 key: "!/Esc",
3179 desc: "back",
3180 },
3181 ],
3182 rows[2].width,
3183 ))
3184 }
3185 ViewMode::Tree => {
3186 let node_count = self.tree_flat_nodes.len();
3187 Some(styled_mode_footer(
3188 &format!("Tree: {node_count} nodes"),
3189 &[
3190 CommandHint {
3191 key: "j/k",
3192 desc: "navigate",
3193 },
3194 CommandHint {
3195 key: "Enter",
3196 desc: "expand/collapse",
3197 },
3198 CommandHint {
3199 key: "Tab",
3200 desc: "focus",
3201 },
3202 CommandHint {
3203 key: "T/Esc",
3204 desc: "back",
3205 },
3206 ],
3207 rows[2].width,
3208 ))
3209 }
3210 ViewMode::LabelDashboard => {
3211 let label_count = self.label_dashboard.as_ref().map_or(0, |r| r.labels.len());
3212 Some(styled_mode_footer(
3213 &format!("Labels: {label_count}"),
3214 &[
3215 CommandHint {
3216 key: "j/k",
3217 desc: "navigate",
3218 },
3219 CommandHint {
3220 key: "Tab",
3221 desc: "focus",
3222 },
3223 CommandHint {
3224 key: "[/Esc",
3225 desc: "back",
3226 },
3227 ],
3228 rows[2].width,
3229 ))
3230 }
3231 ViewMode::FlowMatrix => {
3232 let label_count = self.flow_matrix.as_ref().map_or(0, |f| f.labels.len());
3233 let dep_count = self
3234 .flow_matrix
3235 .as_ref()
3236 .map_or(0, |f| f.total_cross_label_deps);
3237 Some(styled_mode_footer(
3238 &format!("Flow: {label_count} labels, {dep_count} cross-deps"),
3239 &[
3240 CommandHint {
3241 key: "j/k",
3242 desc: "rows",
3243 },
3244 CommandHint {
3245 key: "h/l",
3246 desc: "cols",
3247 },
3248 CommandHint {
3249 key: "Tab",
3250 desc: "focus",
3251 },
3252 CommandHint {
3253 key: "]/Esc",
3254 desc: "back",
3255 },
3256 ],
3257 rows[2].width,
3258 ))
3259 }
3260 ViewMode::TimeTravelDiff => {
3261 if self.time_travel_input_active {
3262 Some(styled_mode_footer(
3263 "Time-travel: enter git ref or file path",
3264 &[
3265 CommandHint {
3266 key: "Enter",
3267 desc: "confirm",
3268 },
3269 CommandHint {
3270 key: "Esc",
3271 desc: "cancel",
3272 },
3273 ],
3274 rows[2].width,
3275 ))
3276 } else if self.time_travel_diff.is_some() {
3277 Some(styled_mode_footer(
3278 "Time-travel",
3279 &[
3280 CommandHint {
3281 key: "j/k",
3282 desc: "navigate",
3283 },
3284 CommandHint {
3285 key: "Tab",
3286 desc: "focus",
3287 },
3288 CommandHint {
3289 key: "T",
3290 desc: "reload",
3291 },
3292 CommandHint {
3293 key: "t/Esc",
3294 desc: "back",
3295 },
3296 ],
3297 rows[2].width,
3298 ))
3299 } else {
3300 Some(styled_mode_footer(
3301 "Time-travel: no diff loaded",
3302 &[
3303 CommandHint {
3304 key: "t",
3305 desc: "enter ref",
3306 },
3307 CommandHint {
3308 key: "Esc",
3309 desc: "back",
3310 },
3311 ],
3312 rows[2].width,
3313 ))
3314 }
3315 }
3316 ViewMode::Sprint => {
3317 let sprint_count = self.sprint_data.len();
3318 Some(styled_mode_footer(
3319 &format!("Sprint: {sprint_count} sprint(s)"),
3320 &[
3321 CommandHint {
3322 key: "j/k",
3323 desc: "navigate",
3324 },
3325 CommandHint {
3326 key: "Tab",
3327 desc: "focus",
3328 },
3329 CommandHint {
3330 key: "S/Esc",
3331 desc: "back",
3332 },
3333 ],
3334 rows[2].width,
3335 ))
3336 }
3337 };
3338 let footer_text = footer_text.unwrap_or_else(|| match self.mode {
3339 ViewMode::Main => {
3340 let hints = self.main_footer_command_hints();
3341 wrap_command_hints(&hints, rows[2].width.saturating_sub(1) as usize)
3342 }
3343 ViewMode::Graph => {
3344 let hints = self.graph_footer_command_hints();
3345 wrap_command_hints(&hints, rows[2].width.saturating_sub(1) as usize)
3346 }
3347 ViewMode::History => {
3348 if self.history_file_tree_focus {
3349 let mut hints = vec![
3350 CommandHint {
3351 key: "j/k",
3352 desc: "tree",
3353 },
3354 CommandHint {
3355 key: "Enter",
3356 desc: "filter",
3357 },
3358 CommandHint {
3359 key: "Tab",
3360 desc: "panes",
3361 },
3362 CommandHint {
3363 key: "Esc",
3364 desc: "close tree",
3365 },
3366 ];
3367 hints.extend([
3368 CommandHint {
3369 key: "^←/→",
3370 desc: "resize",
3371 },
3372 CommandHint {
3373 key: "^0",
3374 desc: "reset split",
3375 },
3376 ]);
3377 wrap_command_hints(&hints, rows[2].width.saturating_sub(1) as usize)
3378 } else {
3379 let confidence = format!(
3380 "confidence >= {:.0}%",
3381 self.history_min_confidence() * 100.0
3382 );
3383 let mut hints = vec![
3384 CommandHint {
3385 key: "c",
3386 desc: confidence.as_str(),
3387 },
3388 CommandHint {
3389 key: "v",
3390 desc: "bead/git",
3391 },
3392 CommandHint {
3393 key: "/",
3394 desc: "search",
3395 },
3396 CommandHint {
3397 key: "Tab",
3398 desc: "mode",
3399 },
3400 CommandHint {
3401 key: "y",
3402 desc: "copy",
3403 },
3404 ];
3405 if self.history_selected_commit_url().is_some() {
3406 hints.push(CommandHint {
3407 key: "o",
3408 desc: "open commit",
3409 });
3410 }
3411 hints.extend([
3412 CommandHint {
3413 key: "f",
3414 desc: "file-tree",
3415 },
3416 CommandHint {
3417 key: "h/Esc",
3418 desc: "back",
3419 },
3420 CommandHint {
3421 key: "^←/→",
3422 desc: "resize",
3423 },
3424 CommandHint {
3425 key: "^0",
3426 desc: "reset split",
3427 },
3428 ]);
3429 wrap_command_hints(&hints, rows[2].width.saturating_sub(1) as usize)
3430 }
3431 }
3432 _ => unreachable!("footer rich hints only apply to main/graph/history"),
3433 });
3434 Paragraph::new(footer_text)
3435 .style(tokens::footer())
3436 .render(rows[2], frame);
3437 }
3438}
3439
3440impl BvrApp {
3441 fn header_mode_tab_at(&self, x: u16, y: u16) -> Option<ViewMode> {
3442 if y != 0 {
3443 return None;
3444 }
3445
3446 header_mode_tabs(self, cached_view_width())
3447 .into_iter()
3448 .find(|tab| rect_contains(tab.rect, x, y))
3449 .map(|tab| tab.mode)
3450 }
3451
3452 fn push_mode_stack(&mut self) {
3454 if self.mode_back_stack.last() != Some(&self.mode) {
3455 self.mode_back_stack.push(self.mode);
3456 if self.mode_back_stack.len() > 10 {
3458 self.mode_back_stack.remove(0);
3459 }
3460 }
3461 }
3462
3463 fn activate_mode_tab(&mut self, mode: ViewMode) {
3464 self.push_mode_stack();
3465 match mode {
3466 ViewMode::Main => {
3467 self.mode = ViewMode::Main;
3468 self.focus = FocusPane::List;
3469 }
3470 ViewMode::Board => {
3471 self.mode = ViewMode::Board;
3472 self.focus = FocusPane::List;
3473 }
3474 ViewMode::Insights => {
3475 self.mode = ViewMode::Insights;
3476 self.focus = FocusPane::List;
3477 }
3478 ViewMode::Graph => {
3479 self.mode = ViewMode::Graph;
3480 self.focus = FocusPane::List;
3481 }
3482 ViewMode::History => {
3483 if !matches!(self.mode, ViewMode::History) {
3484 self.toggle_history_mode();
3485 }
3486 self.focus = FocusPane::List;
3487 }
3488 ViewMode::Actionable => {
3489 if !matches!(self.mode, ViewMode::Actionable) {
3490 self.compute_actionable_plan();
3491 self.mode = ViewMode::Actionable;
3492 }
3493 self.detail_scroll_offset = 0;
3494 self.focus = FocusPane::List;
3495 }
3496 ViewMode::Attention => {
3497 if !matches!(self.mode, ViewMode::Attention) {
3498 self.compute_attention();
3499 self.mode = ViewMode::Attention;
3500 }
3501 self.focus = FocusPane::List;
3502 }
3503 ViewMode::Tree => {
3504 if !matches!(self.mode, ViewMode::Tree) {
3505 self.toggle_tree_mode();
3506 }
3507 self.focus = FocusPane::List;
3508 }
3509 ViewMode::LabelDashboard => {
3510 if !matches!(self.mode, ViewMode::LabelDashboard) {
3511 self.toggle_label_dashboard();
3512 }
3513 self.focus = FocusPane::List;
3514 }
3515 ViewMode::FlowMatrix => {
3516 if !matches!(self.mode, ViewMode::FlowMatrix) {
3517 self.toggle_flow_matrix();
3518 }
3519 self.focus = FocusPane::List;
3520 }
3521 ViewMode::TimeTravelDiff => {
3522 if !matches!(self.mode, ViewMode::TimeTravelDiff) {
3523 self.toggle_time_travel_mode();
3524 }
3525 }
3526 ViewMode::Sprint => {
3527 if !matches!(self.mode, ViewMode::Sprint) {
3528 self.toggle_sprint_mode();
3529 }
3530 self.focus = FocusPane::List;
3531 }
3532 }
3533 self.status_msg = format!("Switched to {}", self.mode.label());
3534 }
3535
3536 fn splitter_hit_target_at(&self, x: u16, y: u16) -> Option<SplitterHitBox> {
3537 splitter_hit_boxes(self, cached_view_width(), cached_view_height())
3538 .into_iter()
3539 .find(|hit_box| rect_contains(hit_box.rect, x, y))
3540 }
3541
3542 fn adjust_splitter_target(
3543 &mut self,
3544 target: SplitterTarget,
3545 delta_pct: f32,
3546 source: &'static str,
3547 ) -> bool {
3548 let mut state = pane_split_state();
3549 let changed = state.adjust_splitter_target(target, delta_pct);
3550 if changed {
3551 set_pane_split_state(state);
3552 self.status_msg = format!("Pane split adjusted ({source}, {delta_pct:+.0}%)");
3553 }
3554 changed
3555 }
3556
3557 fn handle_splitter_mouse_scroll(&mut self, event: MouseEvent) -> Option<Cmd<Msg>> {
3558 let hit_box = self.splitter_hit_target_at(event.x, event.y)?;
3559 let delta_pct = match event.kind {
3560 MouseEventKind::ScrollUp => 4.0,
3561 MouseEventKind::ScrollDown => -4.0,
3562 _ => return None,
3563 };
3564 self.adjust_splitter_target(hit_box.target, delta_pct, "mouse");
3565 Some(Cmd::None)
3566 }
3567
3568 fn handle_splitter_mouse_click(&mut self, event: MouseEvent) -> Option<Cmd<Msg>> {
3569 if !matches!(event.kind, MouseEventKind::Down(MouseButton::Left)) {
3570 return None;
3571 }
3572
3573 let hit_box = self.splitter_hit_target_at(event.x, event.y)?;
3574 let midpoint = hit_box.rect.x.saturating_add(hit_box.rect.width / 2);
3575 let expand_leading = event.x < midpoint;
3576 let delta_pct = if expand_leading { 4.0 } else { -4.0 };
3577
3578 self.focus = match (hit_box.target, expand_leading) {
3579 (SplitterTarget::TwoPane { .. }, true) => FocusPane::List,
3580 (SplitterTarget::TwoPane { .. }, false) => FocusPane::Detail,
3581 (SplitterTarget::HistoryThree { divider, .. }, true) if divider == 0 => FocusPane::List,
3582 (SplitterTarget::HistoryThree { divider, .. }, false) if divider == 0 => {
3583 FocusPane::Middle
3584 }
3585 (SplitterTarget::HistoryThree { .. }, true) => FocusPane::Middle,
3586 (SplitterTarget::HistoryThree { .. }, false) => FocusPane::Detail,
3587 (SplitterTarget::HistoryFour { divider }, true) if divider == 0 => FocusPane::List,
3588 (SplitterTarget::HistoryFour { divider }, false) if divider == 0 => FocusPane::Middle,
3589 (SplitterTarget::HistoryFour { divider }, true) if divider == 1 => FocusPane::Middle,
3590 (SplitterTarget::HistoryFour { divider }, false) if divider == 1 => FocusPane::Middle,
3591 (SplitterTarget::HistoryFour { .. }, true) => FocusPane::Middle,
3592 (SplitterTarget::HistoryFour { .. }, false) => FocusPane::Detail,
3593 };
3594
3595 self.adjust_splitter_target(hit_box.target, delta_pct, "mouse");
3596 Some(Cmd::None)
3597 }
3598
3599 fn handle_header_mouse_click(&mut self, event: MouseEvent) -> Option<Cmd<Msg>> {
3600 if !matches!(event.kind, MouseEventKind::Down(MouseButton::Left)) {
3601 return None;
3602 }
3603
3604 let mode = self.header_mode_tab_at(event.x, event.y)?;
3605 self.activate_mode_tab(mode);
3606 Some(Cmd::None)
3607 }
3608
3609 fn adjust_active_pane_split(&mut self, delta_pct: f32) -> bool {
3610 let mut state = pane_split_state();
3611 let changed = if matches!(self.mode, ViewMode::History) {
3612 state.adjust_history(
3613 self.history_layout(),
3614 self.history_view_mode,
3615 self.focus,
3616 delta_pct,
3617 )
3618 } else if matches!(self.mode, ViewMode::Graph)
3619 && matches!(
3620 Breakpoint::from_width(cached_view_width()),
3621 Breakpoint::Narrow
3622 )
3623 {
3624 false
3625 } else {
3626 state.adjust_two_pane(Breakpoint::from_width(cached_view_width()), delta_pct)
3627 };
3628 if changed {
3629 set_pane_split_state(state);
3630 self.status_msg = format!("Pane split adjusted ({delta_pct:+.0}%)");
3631 }
3632 changed
3633 }
3634
3635 fn history_layout(&self) -> HistoryLayout {
3636 HistoryLayout::from_width(cached_view_width())
3637 }
3638
3639 fn history_has_middle_pane(&self) -> bool {
3640 matches!(self.mode, ViewMode::History) && self.history_layout().has_middle_pane()
3641 }
3642
3643 fn history_list_panel_title(&self) -> &'static str {
3644 if matches!(self.history_view_mode, HistoryViewMode::Git) {
3645 "Commits"
3646 } else {
3647 "Beads With History"
3648 }
3649 }
3650
3651 fn history_middle_panel_title(&self) -> &'static str {
3652 if matches!(self.history_view_mode, HistoryViewMode::Git) {
3653 "Related Beads"
3654 } else {
3655 "Commits"
3656 }
3657 }
3658
3659 fn history_detail_panel_title(&self) -> &'static str {
3660 "Commit Details"
3661 }
3662
3663 fn history_timeline_panel_title(&self) -> String {
3664 self.selected_issue()
3665 .map(|issue| format!("Timeline: {}", issue.id))
3666 .unwrap_or_else(|| "Timeline".to_string())
3667 }
3668
3669 fn reset_pane_split_state(&mut self) -> bool {
3670 let default_state = PaneSplitState::default();
3671 if pane_split_state() == default_state {
3672 return false;
3673 }
3674
3675 set_pane_split_state(default_state);
3676 self.status_msg = "Pane splits reset".to_string();
3677 true
3678 }
3679
3680 #[cfg(not(test))]
3681 fn wrap_quit_with_background_cancel(&self, cmd: Cmd<Msg>) -> Cmd<Msg> {
3682 if matches!(cmd, Cmd::Quit)
3683 && let Some(runtime) = self.background_runtime.as_ref()
3684 {
3685 runtime.cancel_requested.store(true, Ordering::Relaxed);
3686 }
3687 cmd
3688 }
3689
3690 #[cfg(not(test))]
3691 fn background_tick_command(&self) -> Cmd<Msg> {
3692 self.background_runtime
3693 .as_ref()
3694 .map_or(Cmd::None, |runtime| {
3695 Cmd::tick(runtime.config.poll_interval())
3696 })
3697 }
3698
3699 #[cfg(not(test))]
3700 fn handle_background_tick(&mut self) -> Cmd<Msg> {
3701 if self.slow_metrics_pending {
3703 if let Some(rx) = self.slow_metrics_rx.as_ref() {
3704 if let Ok(slow) = rx.try_recv() {
3705 self.analyzer.apply_slow_metrics(slow);
3706 self.slow_metrics_pending = false;
3707 self.slow_metrics_rx = None;
3708 self.status_msg = "Background metrics computed".to_string();
3709 }
3710 }
3711 }
3712
3713 let next_tick = self.background_tick_command();
3714 let Some(runtime) = self.background_runtime.as_mut() else {
3715 return Cmd::None;
3716 };
3717
3718 let decision = decide_background_tick(
3719 runtime.cancel_requested.load(Ordering::Relaxed),
3720 runtime.in_flight,
3721 );
3722
3723 let (config, cancel_requested) = match decision {
3724 BackgroundTickDecision::Stop => {
3725 self.history_status_msg =
3726 push_background_timeline(runtime, "tick skipped: cancellation requested");
3727 return Cmd::None;
3728 }
3729 BackgroundTickDecision::TickOnly => {
3730 self.history_status_msg =
3731 push_background_timeline(runtime, "tick observed: reload already in flight");
3732 return next_tick;
3733 }
3734 BackgroundTickDecision::ReloadAndTick => {
3735 runtime.in_flight = true;
3736 self.history_status_msg =
3737 push_background_timeline(runtime, "tick scheduled: starting background reload");
3738 (runtime.config.clone(), runtime.cancel_requested.clone())
3739 }
3740 };
3741
3742 let task = Cmd::task_with_spec(
3743 TaskSpec::new(1.0, 50.0).with_name("background-issue-reload"),
3744 move || {
3745 if cancel_requested.load(Ordering::Relaxed) {
3746 return Msg::BackgroundReloaded(Err("canceled".to_string()));
3747 }
3748
3749 let result = config.load_issues().map_err(|error| error.to_string());
3750 Msg::BackgroundReloaded(result)
3751 },
3752 );
3753
3754 Cmd::batch(vec![task, next_tick])
3755 }
3756
3757 #[cfg(not(test))]
3758 fn handle_background_reload_result(&mut self, result: std::result::Result<Vec<Issue>, String>) {
3759 let mut issues_to_apply: Option<Vec<Issue>> = None;
3760 let status_update: String;
3761
3762 {
3763 let Some(runtime) = self.background_runtime.as_mut() else {
3764 return;
3765 };
3766
3767 runtime.in_flight = false;
3768 let cancel_requested = runtime.cancel_requested.load(Ordering::Relaxed);
3769
3770 match result {
3771 Ok(issues) => {
3772 let hash = compute_data_hash(&issues);
3773 if should_apply_background_reload(
3774 cancel_requested,
3775 &hash,
3776 &runtime.last_data_hash,
3777 ) {
3778 runtime.last_data_hash = hash;
3779 status_update = push_background_timeline(
3780 runtime,
3781 "reload applied: issue snapshot changed",
3782 );
3783 issues_to_apply = Some(issues);
3784 } else if cancel_requested {
3785 status_update = push_background_timeline(
3786 runtime,
3787 "reload ignored: cancellation requested",
3788 );
3789 } else {
3790 status_update =
3791 push_background_timeline(runtime, "reload ignored: no data change");
3792 }
3793 }
3794 Err(error) => {
3795 if let Some(warning) = background_warning_message(cancel_requested, &error) {
3796 status_update = push_background_timeline(runtime, &warning);
3797 } else {
3798 status_update = push_background_timeline(
3799 runtime,
3800 "reload ignored: cancellation acknowledged",
3801 );
3802 }
3803 }
3804 }
3805 }
3806
3807 self.history_status_msg = status_update;
3808 if let Some(issues) = issues_to_apply {
3809 self.apply_background_reload(issues);
3810 }
3811 }
3812
3813 #[cfg(not(test))]
3814 fn apply_background_reload(&mut self, issues: Vec<Issue>) {
3815 let selected_id = self.selected_issue().map(|issue| issue.id.clone());
3816
3817 let use_two_phase =
3818 issues.len() > crate::analysis::graph::AnalysisConfig::background_threshold();
3819 if use_two_phase {
3820 self.analyzer = Analyzer::new_fast(issues);
3821 #[cfg(not(test))]
3822 {
3823 self.slow_metrics_rx = Some(self.analyzer.spawn_slow_computation());
3824 }
3825 self.slow_metrics_pending = true;
3826 } else {
3827 self.analyzer = Analyzer::new(issues);
3828 self.slow_metrics_pending = false;
3829 #[cfg(not(test))]
3830 {
3831 self.slow_metrics_rx = None;
3832 }
3833 }
3834 self.history_git_cache = None;
3835 self.detail_dep_cursor = 0;
3836 self.board_detail_scroll_offset = 0;
3837 self.detail_scroll_offset = 0;
3838 self.selected = 0;
3839
3840 if let Some(id) = selected_id.as_deref() {
3841 self.select_issue_by_id(id);
3842 }
3843
3844 if !self.preserve_off_queue_ranked_context() {
3845 self.ensure_selected_visible();
3846 }
3847 self.sync_insights_heatmap_selection();
3848
3849 self.rebuild_tree_if_active();
3854 }
3855
3856 fn board_shortcut_focus(&self) -> bool {
3857 matches!(self.mode, ViewMode::Board)
3858 && matches!(self.focus, FocusPane::List | FocusPane::Detail)
3859 }
3860
3861 fn actionable_shortcut_focus(&self) -> bool {
3862 matches!(self.mode, ViewMode::Actionable)
3863 && matches!(self.focus, FocusPane::List | FocusPane::Detail)
3864 }
3865
3866 fn attention_shortcut_focus(&self) -> bool {
3867 matches!(self.mode, ViewMode::Attention)
3868 && matches!(self.focus, FocusPane::List | FocusPane::Detail)
3869 }
3870
3871 fn tree_shortcut_focus(&self) -> bool {
3872 matches!(self.mode, ViewMode::Tree)
3873 && matches!(self.focus, FocusPane::List | FocusPane::Detail)
3874 }
3875
3876 fn label_dashboard_shortcut_focus(&self) -> bool {
3877 matches!(self.mode, ViewMode::LabelDashboard)
3878 && matches!(self.focus, FocusPane::List | FocusPane::Detail)
3879 }
3880
3881 fn flow_matrix_shortcut_focus(&self) -> bool {
3882 matches!(self.mode, ViewMode::FlowMatrix)
3883 && matches!(self.focus, FocusPane::List | FocusPane::Detail)
3884 }
3885
3886 fn time_travel_shortcut_focus(&self) -> bool {
3887 matches!(self.mode, ViewMode::TimeTravelDiff)
3888 && matches!(self.focus, FocusPane::List | FocusPane::Detail)
3889 && !self.time_travel_input_active
3890 }
3891
3892 fn sprint_shortcut_focus(&self) -> bool {
3893 matches!(self.mode, ViewMode::Sprint)
3894 && matches!(self.focus, FocusPane::List | FocusPane::Detail)
3895 }
3896
3897 fn insights_heatmap_shortcut_focus(&self) -> bool {
3898 matches!(self.mode, ViewMode::Insights)
3899 && self.focus == FocusPane::List
3900 && self.insights_heatmap.is_some()
3901 }
3902
3903 fn insights_heatmap_data(&self) -> InsightsHeatmapData {
3904 let mut counts =
3905 vec![vec![0; INSIGHTS_HEATMAP_SCORE_LABELS.len()]; INSIGHTS_HEATMAP_DEPTH_LABELS.len()];
3906 let mut issue_ids = vec![
3907 vec![Vec::new(); INSIGHTS_HEATMAP_SCORE_LABELS.len()];
3908 INSIGHTS_HEATMAP_DEPTH_LABELS.len()
3909 ];
3910 let recommendations = self.analyzer.triage(TriageOptions {
3911 max_recommendations: self.analyzer.issues.len().max(50),
3912 ..TriageOptions::default()
3913 });
3914
3915 for recommendation in recommendations.result.recommendations {
3916 let Some(issue) = self
3917 .analyzer
3918 .issues
3919 .iter()
3920 .find(|issue| issue.id == recommendation.id)
3921 else {
3922 continue;
3923 };
3924
3925 if !issue.is_open_like() || !self.issue_matches_filter(issue) {
3926 continue;
3927 }
3928
3929 let depth = self
3930 .analyzer
3931 .metrics
3932 .critical_depth
3933 .get(&issue.id)
3934 .copied()
3935 .unwrap_or_default();
3936 let row = match depth {
3937 0 => 0,
3938 1 | 2 => 1,
3939 3..=5 => 2,
3940 6..=10 => 3,
3941 _ => 4,
3942 };
3943
3944 let score = recommendation.score.clamp(0.0, 1.0);
3945 let col = if score <= 0.2 {
3946 0
3947 } else if score <= 0.4 {
3948 1
3949 } else if score <= 0.6 {
3950 2
3951 } else if score <= 0.8 {
3952 3
3953 } else {
3954 4
3955 };
3956
3957 counts[row][col] += 1;
3958 issue_ids[row][col].push(issue.id.clone());
3959 }
3960
3961 InsightsHeatmapData { counts, issue_ids }
3962 }
3963
3964 fn insights_heatmap_issue_ids_for_current_cell(&self) -> Vec<String> {
3965 let Some(state) = self.insights_heatmap.as_ref() else {
3966 return Vec::new();
3967 };
3968 let data = self.insights_heatmap_data();
3969 let row = state
3970 .row
3971 .min(INSIGHTS_HEATMAP_DEPTH_LABELS.len().saturating_sub(1));
3972 let col = state
3973 .col
3974 .min(INSIGHTS_HEATMAP_SCORE_LABELS.len().saturating_sub(1));
3975 data.issue_ids[row][col].clone()
3976 }
3977
3978 fn sync_insights_heatmap_selection(&mut self) {
3979 let Some(mut state) = self.insights_heatmap.clone() else {
3980 return;
3981 };
3982 let data = self.insights_heatmap_data();
3983 let row = state
3984 .row
3985 .min(INSIGHTS_HEATMAP_DEPTH_LABELS.len().saturating_sub(1));
3986 let col = state
3987 .col
3988 .min(INSIGHTS_HEATMAP_SCORE_LABELS.len().saturating_sub(1));
3989 let cell_issue_ids = data.issue_ids[row][col].clone();
3990
3991 state.row = row;
3992 state.col = col;
3993
3994 if cell_issue_ids.is_empty() {
3995 for (next_row, row_issue_ids) in data.issue_ids.iter().enumerate() {
3996 if let Some((next_col, next_cell_ids)) = row_issue_ids
3997 .iter()
3998 .enumerate()
3999 .find(|(_, ids)| !ids.is_empty())
4000 {
4001 state.row = next_row;
4002 state.col = next_col;
4003 state.drill_active = false;
4004 state.drill_cursor = 0;
4005 let issue_id = next_cell_ids[0].clone();
4006 self.insights_heatmap = Some(state);
4007 self.select_issue_by_id(&issue_id);
4008 return;
4009 }
4010 }
4011
4012 state.drill_active = false;
4013 state.drill_cursor = 0;
4014 self.insights_heatmap = Some(state);
4015 return;
4016 }
4017
4018 if !state.drill_active {
4019 state.drill_cursor = 0;
4020 } else {
4021 state.drill_cursor = state
4022 .drill_cursor
4023 .min(cell_issue_ids.len().saturating_sub(1));
4024 }
4025
4026 let issue_id = cell_issue_ids[state.drill_cursor].clone();
4027 self.insights_heatmap = Some(state);
4028 self.select_issue_by_id(&issue_id);
4029 }
4030
4031 fn toggle_insights_heatmap(&mut self) {
4032 if self.insights_heatmap.is_some() {
4033 self.insights_heatmap = None;
4034 return;
4035 }
4036
4037 self.insights_heatmap = Some(InsightsHeatmapState::default());
4038 self.sync_insights_heatmap_selection();
4039 }
4040
4041 fn enter_insights_heatmap_drill(&mut self) {
4042 let cell_issue_ids = self.insights_heatmap_issue_ids_for_current_cell();
4043 if cell_issue_ids.is_empty() {
4044 return;
4045 }
4046
4047 if let Some(state) = self.insights_heatmap.as_mut() {
4048 state.drill_active = true;
4049 state.drill_cursor = 0;
4050 }
4051 self.sync_insights_heatmap_selection();
4052 }
4053
4054 fn exit_insights_heatmap_drill(&mut self) -> bool {
4055 let Some(state) = self.insights_heatmap.as_mut() else {
4056 return false;
4057 };
4058 if !state.drill_active {
4059 return false;
4060 }
4061
4062 state.drill_active = false;
4063 state.drill_cursor = 0;
4064 self.sync_insights_heatmap_selection();
4065 true
4066 }
4067
4068 fn move_insights_heatmap_row(&mut self, delta: isize) {
4069 let Some(state) = self.insights_heatmap.as_mut() else {
4070 return;
4071 };
4072 if state.drill_active || delta == 0 {
4073 return;
4074 }
4075
4076 let max_row = INSIGHTS_HEATMAP_DEPTH_LABELS.len().saturating_sub(1);
4077 state.row = if delta >= 0 {
4078 state.row.saturating_add(delta.unsigned_abs()).min(max_row)
4079 } else {
4080 state.row.saturating_sub(delta.unsigned_abs())
4081 };
4082 self.sync_insights_heatmap_selection();
4083 }
4084
4085 fn move_insights_heatmap_col(&mut self, delta: isize) {
4086 let Some(state) = self.insights_heatmap.as_mut() else {
4087 return;
4088 };
4089 if state.drill_active || delta == 0 {
4090 return;
4091 }
4092
4093 let max_col = INSIGHTS_HEATMAP_SCORE_LABELS.len().saturating_sub(1);
4094 state.col = if delta >= 0 {
4095 state.col.saturating_add(delta.unsigned_abs()).min(max_col)
4096 } else {
4097 state.col.saturating_sub(delta.unsigned_abs())
4098 };
4099 self.sync_insights_heatmap_selection();
4100 }
4101
4102 fn move_insights_heatmap_drill(&mut self, delta: isize) {
4103 let cell_issue_ids = self.insights_heatmap_issue_ids_for_current_cell();
4104 let Some(state) = self.insights_heatmap.as_mut() else {
4105 return;
4106 };
4107 if !state.drill_active || delta == 0 || cell_issue_ids.is_empty() {
4108 return;
4109 }
4110
4111 let max_slot = cell_issue_ids.len().saturating_sub(1);
4112 state.drill_cursor = if delta >= 0 {
4113 state
4114 .drill_cursor
4115 .saturating_add(delta.unsigned_abs())
4116 .min(max_slot)
4117 } else {
4118 state.drill_cursor.saturating_sub(delta.unsigned_abs())
4119 };
4120 self.sync_insights_heatmap_selection();
4121 }
4122
4123 fn handle_mouse(&mut self, event: MouseEvent) -> Cmd<Msg> {
4124 if let Some(cmd) = self.handle_header_mouse_click(event) {
4125 return cmd;
4126 }
4127
4128 if let Some(cmd) = self.handle_splitter_mouse_scroll(event) {
4129 return cmd;
4130 }
4131
4132 if let Some(cmd) = self.handle_splitter_mouse_click(event) {
4133 return cmd;
4134 }
4135
4136 match event.kind {
4137 MouseEventKind::ScrollUp => self.handle_key(KeyCode::Up, Modifiers::NONE),
4138 MouseEventKind::ScrollDown => self.handle_key(KeyCode::Down, Modifiers::NONE),
4139 MouseEventKind::Down(MouseButton::Left)
4140 if self.mouse_open_detail_link(event.x, event.y) =>
4141 {
4142 Cmd::None
4143 }
4144 MouseEventKind::Down(MouseButton::Right)
4145 if self.mouse_copy_detail_link(event.x, event.y) =>
4146 {
4147 Cmd::None
4148 }
4149 _ => Cmd::None,
4150 }
4151 }
4152
4153 fn handle_key(&mut self, code: KeyCode, modifiers: Modifiers) -> Cmd<Msg> {
4154 if !self.status_msg.is_empty() {
4156 self.status_msg.clear();
4157 }
4158
4159 if self.show_quit_confirm {
4160 match code {
4161 KeyCode::Escape | KeyCode::Char('y' | 'Y') => return Cmd::Quit,
4162 _ => {
4163 self.show_quit_confirm = false;
4164 self.focus = FocusPane::List;
4165 return Cmd::None;
4166 }
4167 }
4168 }
4169
4170 if self.show_help {
4171 match code {
4172 KeyCode::Char('?' | 'q') | KeyCode::Escape | KeyCode::F(1) => {
4173 self.show_help = false;
4174 self.help_scroll_offset = 0;
4175 self.focus = self.focus_before_help;
4176 }
4177 KeyCode::Char('j') | KeyCode::Down => {
4178 self.help_scroll_offset = self.help_scroll_offset.saturating_add(1);
4179 }
4180 KeyCode::Char('k') | KeyCode::Up => {
4181 self.help_scroll_offset = self.help_scroll_offset.saturating_sub(1);
4182 }
4183 KeyCode::Char('d') if modifiers.contains(Modifiers::CTRL) => {
4184 self.help_scroll_offset = self.help_scroll_offset.saturating_add(10);
4185 }
4186 KeyCode::Char('u') if modifiers.contains(Modifiers::CTRL) => {
4187 self.help_scroll_offset = self.help_scroll_offset.saturating_sub(10);
4188 }
4189 KeyCode::Char('g') | KeyCode::Home => {
4190 self.help_scroll_offset = 0;
4191 }
4192 KeyCode::Char('G') | KeyCode::End => {
4193 self.help_scroll_offset = 999;
4194 }
4195 _ => {}
4196 }
4197 return Cmd::None;
4198 }
4199
4200 if let Some(ref overlay) = self.modal_overlay.clone() {
4202 match overlay {
4203 ModalOverlay::Tutorial => {
4204 self.modal_overlay = None;
4206 return Cmd::None;
4207 }
4208 ModalOverlay::Confirm { resume_overlay, .. } => {
4209 match code {
4210 KeyCode::Char('y' | 'Y') => {
4211 self.modal_confirm_result = Some(true);
4212 self.modal_overlay = None;
4213 }
4214 KeyCode::Char('n' | 'N') | KeyCode::Escape => {
4215 self.modal_confirm_result = Some(false);
4216 self.modal_overlay = resume_overlay.clone().map(|overlay| *overlay);
4217 }
4218 _ => {}
4219 }
4220 return Cmd::None;
4221 }
4222 ModalOverlay::PagesWizard(wiz) => {
4223 return self.handle_pages_wizard_key(code, wiz.clone());
4224 }
4225 ModalOverlay::RecipePicker { items, cursor } => {
4226 let len = items.len();
4227 let cur = *cursor;
4228 match code {
4229 KeyCode::Escape => self.modal_overlay = None,
4230 KeyCode::Char('j') | KeyCode::Down => {
4231 if let Some(ModalOverlay::RecipePicker { cursor, items }) =
4232 &mut self.modal_overlay
4233 {
4234 if *cursor + 1 < items.len() {
4235 *cursor += 1;
4236 }
4237 }
4238 }
4239 KeyCode::Char('k') | KeyCode::Up => {
4240 if let Some(ModalOverlay::RecipePicker { cursor, .. }) =
4241 &mut self.modal_overlay
4242 {
4243 *cursor = cursor.saturating_sub(1);
4244 }
4245 }
4246 KeyCode::Enter if cur < len => {
4247 let recipe_name = items[cur].0.clone();
4248 self.modal_overlay = None;
4249 self.status_msg = format!("Recipe: {recipe_name}");
4250 }
4251 _ => {}
4252 }
4253 return Cmd::None;
4254 }
4255 ModalOverlay::LabelPicker {
4256 items,
4257 cursor,
4258 filter,
4259 } => {
4260 let needle = filter.to_ascii_lowercase();
4261 let filtered: Vec<usize> = items
4262 .iter()
4263 .enumerate()
4264 .filter(|(_, (name, _))| {
4265 needle.is_empty() || name.to_ascii_lowercase().contains(&needle)
4266 })
4267 .map(|(i, _)| i)
4268 .collect();
4269 let flen = filtered.len();
4270 match code {
4271 KeyCode::Escape => self.modal_overlay = None,
4272 KeyCode::Down => {
4273 if let Some(ModalOverlay::LabelPicker { cursor, .. }) =
4274 &mut self.modal_overlay
4275 {
4276 if *cursor + 1 < flen {
4277 *cursor += 1;
4278 }
4279 }
4280 }
4281 KeyCode::Up => {
4282 if let Some(ModalOverlay::LabelPicker { cursor, .. }) =
4283 &mut self.modal_overlay
4284 {
4285 *cursor = cursor.saturating_sub(1);
4286 }
4287 }
4288 KeyCode::Backspace => {
4289 if let Some(ModalOverlay::LabelPicker { filter, cursor, .. }) =
4290 &mut self.modal_overlay
4291 {
4292 filter.pop();
4293 *cursor = 0;
4294 }
4295 }
4296 KeyCode::Enter => {
4297 let actual_idx = filtered.get(*cursor).copied();
4298 if let Some(idx) = actual_idx {
4299 let label = items[idx].0.clone();
4300 self.modal_overlay = None;
4301 self.set_label_filter(&label);
4302 }
4303 }
4304 KeyCode::Char(c) => {
4305 if let Some(ModalOverlay::LabelPicker { filter, cursor, .. }) =
4306 &mut self.modal_overlay
4307 {
4308 filter.push(c);
4309 *cursor = 0;
4310 }
4311 }
4312 _ => {}
4313 }
4314 return Cmd::None;
4315 }
4316 ModalOverlay::RepoPicker {
4317 items,
4318 cursor,
4319 filter,
4320 } => {
4321 let needle = filter.to_ascii_lowercase();
4322 let filtered: Vec<usize> = items
4323 .iter()
4324 .enumerate()
4325 .filter(|(_, name)| {
4326 needle.is_empty() || name.to_ascii_lowercase().contains(&needle)
4327 })
4328 .map(|(i, _)| i)
4329 .collect();
4330 let flen = filtered.len();
4331 match code {
4332 KeyCode::Escape => self.modal_overlay = None,
4333 KeyCode::Down => {
4334 if let Some(ModalOverlay::RepoPicker { cursor, .. }) =
4335 &mut self.modal_overlay
4336 {
4337 if *cursor + 1 < flen {
4338 *cursor += 1;
4339 }
4340 }
4341 }
4342 KeyCode::Up => {
4343 if let Some(ModalOverlay::RepoPicker { cursor, .. }) =
4344 &mut self.modal_overlay
4345 {
4346 *cursor = cursor.saturating_sub(1);
4347 }
4348 }
4349 KeyCode::Backspace => {
4350 if let Some(ModalOverlay::RepoPicker { filter, cursor, .. }) =
4351 &mut self.modal_overlay
4352 {
4353 filter.pop();
4354 *cursor = 0;
4355 }
4356 }
4357 KeyCode::Enter => {
4358 let actual_idx = filtered.get(*cursor).copied();
4359 if let Some(idx) = actual_idx {
4360 let repo = items[idx].clone();
4361 self.modal_overlay = None;
4362 self.set_repo_filter(&repo);
4363 }
4364 }
4365 KeyCode::Char(c) => {
4366 if let Some(ModalOverlay::RepoPicker { filter, cursor, .. }) =
4367 &mut self.modal_overlay
4368 {
4369 filter.push(c);
4370 *cursor = 0;
4371 }
4372 }
4373 _ => {}
4374 }
4375 return Cmd::None;
4376 }
4377 }
4378 }
4379
4380 if matches!(
4382 (code, modifiers.contains(Modifiers::CTRL)),
4383 (KeyCode::Char('r'), true) | (KeyCode::F(5), _)
4384 ) {
4385 self.refresh_from_disk();
4386 return Cmd::None;
4387 }
4388
4389 if matches!(
4390 (code, modifiers.contains(Modifiers::CTRL)),
4391 (KeyCode::Char('0'), true)
4392 ) {
4393 self.reset_pane_split_state();
4394 return Cmd::None;
4395 }
4396
4397 if !self.preserve_off_queue_ranked_context() {
4398 self.ensure_selected_visible();
4399 }
4400
4401 if matches!(self.mode, ViewMode::Main)
4402 && self.focus == FocusPane::List
4403 && self.main_search_active
4404 {
4405 match code {
4406 KeyCode::Escape => self.cancel_main_search(),
4407 KeyCode::Enter => self.finish_main_search(),
4408 KeyCode::Backspace => {
4409 self.main_search_query.pop();
4410 self.main_search_match_cursor = 0;
4411 self.select_current_main_search_match();
4412 }
4413 KeyCode::Char('n') => self.move_main_search_match_relative(1),
4414 KeyCode::Char('N') => self.move_main_search_match_relative(-1),
4415 KeyCode::Char(ch) if !modifiers.contains(Modifiers::CTRL) && !ch.is_control() => {
4416 self.main_search_query.push(ch);
4417 self.main_search_match_cursor = 0;
4418 self.select_current_main_search_match();
4419 }
4420 _ => {}
4421 }
4422 return Cmd::None;
4423 }
4424
4425 if self.board_shortcut_focus() && self.board_search_active {
4426 match code {
4427 KeyCode::Escape => self.cancel_board_search(),
4428 KeyCode::Enter => self.finish_board_search(),
4429 KeyCode::Backspace => {
4430 self.board_search_query.pop();
4431 self.board_search_match_cursor = 0;
4432 self.select_current_board_search_match();
4433 }
4434 KeyCode::Char('n') => self.move_board_search_match_relative(1),
4435 KeyCode::Char('N') => self.move_board_search_match_relative(-1),
4436 KeyCode::Char(ch) if !modifiers.contains(Modifiers::CTRL) && !ch.is_control() => {
4437 self.board_search_query.push(ch);
4438 self.board_search_match_cursor = 0;
4439 self.select_current_board_search_match();
4440 }
4441 _ => {}
4442 }
4443 return Cmd::None;
4444 }
4445
4446 if matches!(self.mode, ViewMode::History)
4447 && self.focus == FocusPane::List
4448 && self.history_search_active
4449 {
4450 match code {
4451 KeyCode::Escape => self.cancel_history_search(),
4452 KeyCode::Enter => self.finish_history_search(),
4453 KeyCode::Tab | KeyCode::BackTab => {
4454 self.history_search_mode = self.history_search_mode.cycle();
4455 self.refresh_history_search_selection();
4456 }
4457 KeyCode::Backspace => {
4458 self.history_search_query.pop();
4459 self.refresh_history_search_selection();
4460 }
4461 KeyCode::Char(ch) if !modifiers.contains(Modifiers::CTRL) && !ch.is_control() => {
4462 self.history_search_query.push(ch);
4463 self.refresh_history_search_selection();
4464 }
4465 _ => {}
4466 }
4467 return Cmd::None;
4468 }
4469
4470 if matches!(self.mode, ViewMode::Graph)
4471 && self.focus == FocusPane::List
4472 && self.graph_search_active
4473 {
4474 match code {
4475 KeyCode::Escape => self.cancel_graph_search(),
4476 KeyCode::Enter => self.finish_graph_search(),
4477 KeyCode::Backspace => {
4478 self.graph_search_query.pop();
4479 self.graph_search_match_cursor = 0;
4480 self.select_current_graph_search_match();
4481 }
4482 KeyCode::Char('n') => self.move_graph_search_match_relative(1),
4483 KeyCode::Char('N') => self.move_graph_search_match_relative(-1),
4484 KeyCode::Char(ch) if !modifiers.contains(Modifiers::CTRL) && !ch.is_control() => {
4485 self.graph_search_query.push(ch);
4486 self.graph_search_match_cursor = 0;
4487 self.select_current_graph_search_match();
4488 }
4489 _ => {}
4490 }
4491 return Cmd::None;
4492 }
4493
4494 if matches!(self.mode, ViewMode::Insights)
4495 && self.focus == FocusPane::List
4496 && self.insights_search_active
4497 {
4498 match code {
4499 KeyCode::Escape => self.cancel_insights_search(),
4500 KeyCode::Enter => self.finish_insights_search(),
4501 KeyCode::Backspace => {
4502 self.insights_search_query.pop();
4503 self.insights_search_match_cursor = 0;
4504 self.select_current_insights_search_match();
4505 }
4506 KeyCode::Char('n') => self.move_insights_search_match_relative(1),
4507 KeyCode::Char('N') => self.move_insights_search_match_relative(-1),
4508 KeyCode::Char(ch) if !modifiers.contains(Modifiers::CTRL) && !ch.is_control() => {
4509 self.insights_search_query.push(ch);
4510 self.insights_search_match_cursor = 0;
4511 self.select_current_insights_search_match();
4512 }
4513 _ => {}
4514 }
4515 return Cmd::None;
4516 }
4517
4518 if matches!(self.mode, ViewMode::Tree)
4520 && self.focus == FocusPane::List
4521 && self.tree_search_active
4522 {
4523 match code {
4524 KeyCode::Escape => self.cancel_tree_search(),
4525 KeyCode::Enter => self.finish_tree_search(),
4526 KeyCode::Backspace => {
4527 self.tree_search_query.pop();
4528 self.tree_search_match_cursor = 0;
4529 self.select_current_tree_search_match();
4530 }
4531 KeyCode::Char('n') => self.move_tree_search_match_relative(1),
4532 KeyCode::Char('N') => self.move_tree_search_match_relative(-1),
4533 KeyCode::Char(ch) if !modifiers.contains(Modifiers::CTRL) && !ch.is_control() => {
4534 self.tree_search_query.push(ch);
4535 self.tree_search_match_cursor = 0;
4536 self.select_current_tree_search_match();
4537 }
4538 _ => {}
4539 }
4540 return Cmd::None;
4541 }
4542
4543 if matches!(self.mode, ViewMode::TimeTravelDiff) && self.time_travel_input_active {
4545 match code {
4546 KeyCode::Escape => {
4547 self.time_travel_input_active = false;
4548 if self.time_travel_diff.is_none() {
4549 self.mode = ViewMode::Main;
4550 self.focus = FocusPane::List;
4551 }
4552 }
4553 KeyCode::Enter => self.execute_time_travel(),
4554 KeyCode::Backspace => {
4555 self.time_travel_ref_input.pop();
4556 }
4557 KeyCode::Char(ch) if !modifiers.contains(Modifiers::CTRL) && !ch.is_control() => {
4558 self.time_travel_ref_input.push(ch);
4559 }
4560 _ => {}
4561 }
4562 return Cmd::None;
4563 }
4564
4565 if self.pending_z {
4567 self.pending_z = false;
4568 if self.tree_shortcut_focus() {
4569 match code {
4570 KeyCode::Char('o') => self.tree_expand_current(),
4571 KeyCode::Char('c') => self.tree_collapse_current(),
4572 KeyCode::Char('a') => self.tree_toggle_collapse(),
4573 KeyCode::Char('R') => self.tree_expand_all(),
4574 KeyCode::Char('M') => self.tree_collapse_all(),
4575 KeyCode::Char('z') => self.tree_recenter_cursor(),
4576 _ => {}
4577 }
4578 }
4579 return Cmd::None;
4580 }
4581
4582 if self.pending_g {
4587 self.pending_g = false;
4588 if code == KeyCode::Char('g') {
4589 if let Some(prev) = self.g_pre_toggle_mode.take() {
4591 self.mode = prev;
4592 }
4593 self.vim_jump_to_top();
4594 return Cmd::None;
4595 }
4596 self.g_pre_toggle_mode = None;
4597 }
4600
4601 match code {
4602 KeyCode::Escape if self.exit_insights_heatmap_drill() => return Cmd::None,
4603 KeyCode::Char('?') => {
4604 self.show_help = true;
4605 self.focus_before_help = self.focus;
4606 }
4607 KeyCode::Enter if self.insights_heatmap_shortcut_focus() => {
4608 self.enter_insights_heatmap_drill();
4609 }
4610 KeyCode::Enter
4611 if matches!(self.mode, ViewMode::History)
4612 && matches!(self.history_view_mode, HistoryViewMode::Bead)
4613 && matches!(self.focus, FocusPane::Middle) =>
4614 {
4615 self.jump_from_history_bead_commit_to_git();
4616 }
4617 KeyCode::Enter
4618 if !(matches!(self.mode, ViewMode::History) && self.history_file_tree_focus)
4619 && !matches!(self.mode, ViewMode::Tree) =>
4620 {
4621 if matches!(self.mode, ViewMode::History)
4622 && matches!(self.history_view_mode, HistoryViewMode::Git)
4623 && let Some(bead_id) = self
4624 .selected_history_git_related_bead_id()
4625 .or_else(|| self.selected_history_event().map(|event| event.issue_id))
4626 {
4627 self.select_issue_by_id(&bead_id);
4628 }
4629 self.mode = ViewMode::Main;
4630 self.focus = FocusPane::Detail;
4631 }
4632 KeyCode::Char('q') => {
4633 if matches!(self.mode, ViewMode::Main) {
4634 return Cmd::Quit;
4635 }
4636 self.mode = ViewMode::Main;
4637 self.focus = FocusPane::List;
4638 }
4639 KeyCode::Char('c') if modifiers.contains(Modifiers::CTRL) => return Cmd::Quit,
4640 KeyCode::Escape => {
4641 if matches!(self.mode, ViewMode::History) && self.history_show_file_tree {
4642 self.history_show_file_tree = false;
4643 self.history_file_tree_focus = false;
4644 self.history_status_msg = "File tree hidden".into();
4645 } else if !matches!(self.mode, ViewMode::Main) {
4646 let prev = self.mode_back_stack.pop().unwrap_or(ViewMode::Main);
4648 self.mode = prev;
4649 self.focus = FocusPane::List;
4650 self.detail_scroll_offset = 0;
4651 } else if matches!(self.focus, FocusPane::Detail) {
4652 self.focus = FocusPane::List;
4653 self.status_msg = "Focus returned to list".into();
4654 } else if !self.main_search_query.is_empty() {
4655 self.cancel_main_search();
4656 self.status_msg = "Main search cleared".into();
4657 } else if self.has_active_filter() {
4658 self.set_list_filter(ListFilter::All);
4659 } else {
4660 self.show_quit_confirm = true;
4661 }
4662 }
4663 KeyCode::Char('j') | KeyCode::Down
4664 if self.insights_heatmap_shortcut_focus()
4665 && self
4666 .insights_heatmap
4667 .as_ref()
4668 .is_some_and(|state| state.drill_active) =>
4669 {
4670 self.move_insights_heatmap_drill(1);
4671 }
4672 KeyCode::Char('k') | KeyCode::Up
4673 if self.insights_heatmap_shortcut_focus()
4674 && self
4675 .insights_heatmap
4676 .as_ref()
4677 .is_some_and(|state| state.drill_active) =>
4678 {
4679 self.move_insights_heatmap_drill(-1);
4680 }
4681 KeyCode::Char('j') | KeyCode::Down if self.insights_heatmap_shortcut_focus() => {
4682 self.move_insights_heatmap_row(1);
4683 }
4684 KeyCode::Char('k') | KeyCode::Up if self.insights_heatmap_shortcut_focus() => {
4685 self.move_insights_heatmap_row(-1);
4686 }
4687 KeyCode::Char('h') if self.insights_heatmap_shortcut_focus() => {
4688 self.move_insights_heatmap_col(-1);
4689 }
4690 KeyCode::Char('l')
4691 if self.insights_heatmap_shortcut_focus()
4692 && self
4693 .insights_heatmap
4694 .as_ref()
4695 .is_some_and(|state| !state.drill_active) =>
4696 {
4697 self.move_insights_heatmap_col(1);
4698 }
4699 KeyCode::Right if modifiers.contains(Modifiers::CTRL) => {
4700 self.adjust_active_pane_split(4.0);
4701 }
4702 KeyCode::Left if modifiers.contains(Modifiers::CTRL) => {
4703 self.adjust_active_pane_split(-4.0);
4704 }
4705 KeyCode::Tab | KeyCode::BackTab => {
4706 let reverse = matches!(code, KeyCode::BackTab);
4707 if matches!(self.mode, ViewMode::History) && self.history_show_file_tree {
4708 if self.history_file_tree_focus {
4709 self.history_file_tree_focus = false;
4710 self.focus = if reverse {
4711 FocusPane::Detail
4712 } else {
4713 FocusPane::List
4714 };
4715 } else if self.history_has_middle_pane() {
4716 match (self.focus, reverse) {
4717 (FocusPane::List, false) => self.focus = FocusPane::Middle,
4718 (FocusPane::Middle, false) => self.focus = FocusPane::Detail,
4719 (FocusPane::Detail, false) => self.history_file_tree_focus = true,
4720 (FocusPane::List, true) => self.history_file_tree_focus = true,
4721 (FocusPane::Middle, true) => self.focus = FocusPane::List,
4722 (FocusPane::Detail, true) => self.focus = FocusPane::Middle,
4723 }
4724 } else if reverse && self.focus == FocusPane::List {
4725 self.history_file_tree_focus = true;
4726 } else if self.focus == FocusPane::Detail {
4727 self.focus = FocusPane::List;
4728 } else if !reverse {
4729 self.history_file_tree_focus = true;
4730 } else {
4731 self.focus = FocusPane::Detail;
4732 }
4733 } else if self.history_has_middle_pane() {
4734 self.focus = match (self.focus, reverse) {
4735 (FocusPane::List, false) => FocusPane::Middle,
4736 (FocusPane::Middle, false) => FocusPane::Detail,
4737 (FocusPane::Detail, false) => FocusPane::List,
4738 (FocusPane::List, true) => FocusPane::Detail,
4739 (FocusPane::Middle, true) => FocusPane::List,
4740 (FocusPane::Detail, true) => FocusPane::Middle,
4741 };
4742 } else {
4743 self.focus = match self.focus {
4744 FocusPane::List => FocusPane::Detail,
4745 FocusPane::Middle => FocusPane::Detail,
4746 FocusPane::Detail => FocusPane::List,
4747 };
4748 }
4749 }
4750 KeyCode::Char('j')
4751 if modifiers.contains(Modifiers::CTRL)
4752 && matches!(self.mode, ViewMode::Board)
4753 && self.focus == FocusPane::Detail =>
4754 {
4755 self.scroll_board_detail(3);
4756 }
4757 KeyCode::Char('k')
4758 if modifiers.contains(Modifiers::CTRL)
4759 && matches!(self.mode, ViewMode::Board)
4760 && self.focus == FocusPane::Detail =>
4761 {
4762 self.scroll_board_detail(-3);
4763 }
4764 KeyCode::Char('j')
4766 if modifiers.contains(Modifiers::CTRL)
4767 && !matches!(self.mode, ViewMode::Board)
4768 && self.focus == FocusPane::Detail =>
4769 {
4770 self.scroll_detail(3);
4771 }
4772 KeyCode::Char('k')
4773 if modifiers.contains(Modifiers::CTRL)
4774 && !matches!(self.mode, ViewMode::Board)
4775 && self.focus == FocusPane::Detail =>
4776 {
4777 self.scroll_detail(-3);
4778 }
4779 KeyCode::Char('h') if self.board_shortcut_focus() => {
4780 self.move_board_lane_relative(-1);
4781 }
4782 KeyCode::Char('l') if self.board_shortcut_focus() => {
4783 self.move_board_lane_relative(1);
4784 }
4785 KeyCode::Char('/') if self.board_shortcut_focus() => {
4786 self.start_board_search();
4787 }
4788 KeyCode::Char('/')
4789 if matches!(self.mode, ViewMode::History) && self.focus == FocusPane::List =>
4790 {
4791 self.start_history_search();
4792 }
4793 KeyCode::Char('/')
4794 if matches!(self.mode, ViewMode::Graph)
4795 && matches!(self.focus, FocusPane::List | FocusPane::Detail) =>
4796 {
4797 self.start_graph_search();
4798 }
4799 KeyCode::Char('/')
4800 if matches!(self.mode, ViewMode::Insights)
4801 && matches!(self.focus, FocusPane::List | FocusPane::Detail) =>
4802 {
4803 self.start_insights_search();
4804 }
4805 KeyCode::Char('/')
4806 if matches!(self.mode, ViewMode::Main) && self.focus == FocusPane::List =>
4807 {
4808 self.start_main_search();
4809 }
4810 KeyCode::Char('n') if self.board_shortcut_focus() => {
4811 self.move_board_search_match_relative(1);
4812 }
4813 KeyCode::Char('N') if self.board_shortcut_focus() => {
4814 self.move_board_search_match_relative(-1);
4815 }
4816 KeyCode::Char('n')
4817 if matches!(self.mode, ViewMode::History)
4818 && self.focus == FocusPane::List
4819 && !self.history_search_query.is_empty() =>
4820 {
4821 self.move_history_search_match_relative(1);
4822 }
4823 KeyCode::Char('N')
4824 if matches!(self.mode, ViewMode::History)
4825 && self.focus == FocusPane::List
4826 && !self.history_search_query.is_empty() =>
4827 {
4828 self.move_history_search_match_relative(-1);
4829 }
4830 KeyCode::Char('n')
4831 if matches!(self.mode, ViewMode::Graph)
4832 && self.focus == FocusPane::List
4833 && !self.graph_search_query.is_empty() =>
4834 {
4835 self.move_graph_search_match_relative(1);
4836 }
4837 KeyCode::Char('N')
4838 if matches!(self.mode, ViewMode::Graph)
4839 && self.focus == FocusPane::List
4840 && !self.graph_search_query.is_empty() =>
4841 {
4842 self.move_graph_search_match_relative(-1);
4843 }
4844 KeyCode::Char('n')
4845 if matches!(self.mode, ViewMode::Insights)
4846 && self.focus == FocusPane::List
4847 && !self.insights_search_query.is_empty() =>
4848 {
4849 self.move_insights_search_match_relative(1);
4850 }
4851 KeyCode::Char('N')
4852 if matches!(self.mode, ViewMode::Insights)
4853 && self.focus == FocusPane::List
4854 && !self.insights_search_query.is_empty() =>
4855 {
4856 self.move_insights_search_match_relative(-1);
4857 }
4858 KeyCode::Char('n')
4859 if matches!(self.mode, ViewMode::Main)
4860 && self.focus == FocusPane::List
4861 && !self.main_search_query.is_empty() =>
4862 {
4863 self.move_main_search_match_relative(1);
4864 }
4865 KeyCode::Char('N')
4866 if matches!(self.mode, ViewMode::Main)
4867 && self.focus == FocusPane::List
4868 && !self.main_search_query.is_empty() =>
4869 {
4870 self.move_main_search_match_relative(-1);
4871 }
4872 KeyCode::Char('j') | KeyCode::Down if self.board_shortcut_focus() => {
4873 self.move_board_row_relative(1);
4874 }
4875 KeyCode::Char('k') | KeyCode::Up if self.board_shortcut_focus() => {
4876 self.move_board_row_relative(-1);
4877 }
4878 KeyCode::Char('j') | KeyCode::Down if self.actionable_shortcut_focus() => {
4879 self.move_actionable_cursor(1);
4880 }
4881 KeyCode::Char('k') | KeyCode::Up if self.actionable_shortcut_focus() => {
4882 self.move_actionable_cursor(-1);
4883 }
4884 KeyCode::Char('j') | KeyCode::Down if self.attention_shortcut_focus() => {
4885 self.move_attention_cursor(1);
4886 }
4887 KeyCode::Char('k') | KeyCode::Up if self.attention_shortcut_focus() => {
4888 self.move_attention_cursor(-1);
4889 }
4890 KeyCode::Char('j') | KeyCode::Down
4892 if self.tree_shortcut_focus()
4893 && self.tree_cursor + 1 < self.tree_flat_nodes.len() =>
4894 {
4895 self.tree_cursor += 1;
4896 }
4897 KeyCode::Char('k') | KeyCode::Up if self.tree_shortcut_focus() => {
4898 self.tree_cursor = self.tree_cursor.saturating_sub(1);
4899 }
4900 KeyCode::Home if self.tree_shortcut_focus() => {
4901 self.tree_cursor = 0;
4902 }
4903 KeyCode::End | KeyCode::Char('G') if self.tree_shortcut_focus() => {
4904 self.tree_cursor = self.tree_flat_nodes.len().saturating_sub(1);
4905 }
4906 KeyCode::PageDown if self.tree_shortcut_focus() => {
4907 let step = self.list_page_step();
4908 self.tree_cursor =
4909 (self.tree_cursor + step).min(self.tree_flat_nodes.len().saturating_sub(1));
4910 }
4911 KeyCode::PageUp if self.tree_shortcut_focus() => {
4912 let step = self.list_page_step();
4913 self.tree_cursor = self.tree_cursor.saturating_sub(step);
4914 }
4915 KeyCode::Enter
4916 if matches!(self.mode, ViewMode::Tree) && self.focus == FocusPane::List =>
4917 {
4918 self.tree_toggle_collapse();
4919 }
4920 KeyCode::Char('z') if self.tree_shortcut_focus() => {
4921 self.pending_z = true;
4922 }
4923 KeyCode::Char('/')
4924 if matches!(self.mode, ViewMode::Tree) && self.focus == FocusPane::List =>
4925 {
4926 self.start_tree_search();
4927 }
4928 KeyCode::Char('n')
4929 if matches!(self.mode, ViewMode::Tree)
4930 && self.focus == FocusPane::List
4931 && !self.tree_search_query.is_empty() =>
4932 {
4933 self.move_tree_search_match_relative(1);
4934 }
4935 KeyCode::Char('N')
4936 if matches!(self.mode, ViewMode::Tree)
4937 && self.focus == FocusPane::List
4938 && !self.tree_search_query.is_empty() =>
4939 {
4940 self.move_tree_search_match_relative(-1);
4941 }
4942 KeyCode::Char('j') | KeyCode::Down if self.label_dashboard_shortcut_focus() => {
4944 let count = self.label_dashboard.as_ref().map_or(0, |r| r.labels.len());
4945 if count > 0 && self.label_dashboard_cursor + 1 < count {
4946 self.label_dashboard_cursor += 1;
4947 }
4948 }
4949 KeyCode::Char('k') | KeyCode::Up if self.label_dashboard_shortcut_focus() => {
4950 self.label_dashboard_cursor = self.label_dashboard_cursor.saturating_sub(1);
4951 }
4952 KeyCode::Char('j') | KeyCode::Down if self.flow_matrix_shortcut_focus() => {
4954 let count = self.flow_matrix.as_ref().map_or(0, |f| f.labels.len());
4955 if count > 0 && self.flow_matrix_row_cursor + 1 < count {
4956 self.flow_matrix_row_cursor += 1;
4957 }
4958 }
4959 KeyCode::Char('k') | KeyCode::Up if self.flow_matrix_shortcut_focus() => {
4960 self.flow_matrix_row_cursor = self.flow_matrix_row_cursor.saturating_sub(1);
4961 }
4962 KeyCode::Char('l') | KeyCode::Right if self.flow_matrix_shortcut_focus() => {
4963 let count = self.flow_matrix.as_ref().map_or(0, |f| f.labels.len());
4964 if count > 0 && self.flow_matrix_col_cursor + 1 < count {
4965 self.flow_matrix_col_cursor += 1;
4966 }
4967 }
4968 KeyCode::Char('h') | KeyCode::Left if self.flow_matrix_shortcut_focus() => {
4969 self.flow_matrix_col_cursor = self.flow_matrix_col_cursor.saturating_sub(1);
4970 }
4971 KeyCode::Char('j') | KeyCode::Down if self.sprint_shortcut_focus() => {
4972 if self.focus == FocusPane::List {
4973 let count = self.sprint_data.len();
4974 if count > 0 && self.sprint_cursor + 1 < count {
4975 self.sprint_cursor += 1;
4976 self.sprint_issue_cursor = 0;
4977 }
4978 } else {
4979 let issue_count = self.sprint_visible_issues().len();
4980 if issue_count > 0 && self.sprint_issue_cursor + 1 < issue_count {
4981 self.sprint_issue_cursor += 1;
4982 }
4983 }
4984 }
4985 KeyCode::Char('k') | KeyCode::Up if self.sprint_shortcut_focus() => {
4986 if self.focus == FocusPane::List {
4987 self.sprint_cursor = self.sprint_cursor.saturating_sub(1);
4988 self.sprint_issue_cursor = 0;
4989 } else {
4990 self.sprint_issue_cursor = self.sprint_issue_cursor.saturating_sub(1);
4991 }
4992 }
4993 KeyCode::Char('j') | KeyCode::Down if self.time_travel_shortcut_focus() => {
4994 if self.focus == FocusPane::List {
4995 let count = self.time_travel_categories().len();
4996 if count > 0 && self.time_travel_category_cursor + 1 < count {
4997 self.time_travel_category_cursor += 1;
4998 self.time_travel_issue_cursor = 0;
4999 }
5000 } else {
5001 let categories = self.time_travel_categories();
5002 let issue_count = categories
5003 .get(self.time_travel_category_cursor)
5004 .map_or(0, |(_, count)| *count);
5005 if issue_count > 0 && self.time_travel_issue_cursor + 1 < issue_count {
5006 self.time_travel_issue_cursor += 1;
5007 }
5008 }
5009 }
5010 KeyCode::Char('k') | KeyCode::Up if self.time_travel_shortcut_focus() => {
5011 if self.focus == FocusPane::List {
5012 self.time_travel_category_cursor =
5013 self.time_travel_category_cursor.saturating_sub(1);
5014 self.time_travel_issue_cursor = 0;
5015 } else {
5016 self.time_travel_issue_cursor = self.time_travel_issue_cursor.saturating_sub(1);
5017 }
5018 }
5019 KeyCode::Char('d')
5020 if modifiers.contains(Modifiers::CTRL)
5021 && matches!(self.mode, ViewMode::Board)
5022 && self.focus == FocusPane::Detail =>
5023 {
5024 self.scroll_board_detail(10);
5025 }
5026 KeyCode::Char('u')
5027 if modifiers.contains(Modifiers::CTRL)
5028 && matches!(self.mode, ViewMode::Board)
5029 && self.focus == FocusPane::Detail =>
5030 {
5031 self.scroll_board_detail(-10);
5032 }
5033 KeyCode::Char('d')
5035 if modifiers.contains(Modifiers::CTRL)
5036 && !matches!(self.mode, ViewMode::Board)
5037 && self.focus == FocusPane::Detail =>
5038 {
5039 self.scroll_detail(10);
5040 }
5041 KeyCode::Char('u')
5042 if modifiers.contains(Modifiers::CTRL)
5043 && !matches!(self.mode, ViewMode::Board)
5044 && self.focus == FocusPane::Detail =>
5045 {
5046 self.scroll_detail(-10);
5047 }
5048 KeyCode::Char('d')
5049 if modifiers.contains(Modifiers::CTRL) && self.board_shortcut_focus() =>
5050 {
5051 self.move_board_row_relative(10);
5052 }
5053 KeyCode::Char('u')
5054 if modifiers.contains(Modifiers::CTRL) && self.board_shortcut_focus() =>
5055 {
5056 self.move_board_row_relative(-10);
5057 }
5058 KeyCode::Char('d')
5059 if modifiers.contains(Modifiers::CTRL)
5060 && matches!(self.mode, ViewMode::Tree)
5061 && self.focus == FocusPane::List =>
5062 {
5063 let half = self.list_page_step() / 2;
5064 self.tree_cursor =
5065 (self.tree_cursor + half).min(self.tree_flat_nodes.len().saturating_sub(1));
5066 }
5067 KeyCode::Char('u')
5068 if modifiers.contains(Modifiers::CTRL)
5069 && matches!(self.mode, ViewMode::Tree)
5070 && self.focus == FocusPane::List =>
5071 {
5072 let half = self.list_page_step() / 2;
5073 self.tree_cursor = self.tree_cursor.saturating_sub(half);
5074 }
5075 KeyCode::Char('d')
5076 if modifiers.contains(Modifiers::CTRL)
5077 && !matches!(self.mode, ViewMode::Board)
5078 && self.focus == FocusPane::List =>
5079 {
5080 self.move_selection_relative(self.list_page_step() as isize);
5081 }
5082 KeyCode::Char('u')
5083 if modifiers.contains(Modifiers::CTRL)
5084 && !matches!(self.mode, ViewMode::Board)
5085 && self.focus == FocusPane::List =>
5086 {
5087 self.move_selection_relative(-(self.list_page_step() as isize));
5088 }
5089 KeyCode::Char('f')
5094 if modifiers.contains(Modifiers::CTRL)
5095 && matches!(self.mode, ViewMode::Tree)
5096 && self.focus == FocusPane::List =>
5097 {
5098 let step = self.list_page_step();
5099 self.tree_cursor =
5100 (self.tree_cursor + step).min(self.tree_flat_nodes.len().saturating_sub(1));
5101 }
5102 KeyCode::Char('b')
5103 if modifiers.contains(Modifiers::CTRL)
5104 && matches!(self.mode, ViewMode::Tree)
5105 && self.focus == FocusPane::List =>
5106 {
5107 let step = self.list_page_step();
5108 self.tree_cursor = self.tree_cursor.saturating_sub(step);
5109 }
5110 KeyCode::Char('f')
5111 if modifiers.contains(Modifiers::CTRL)
5112 && matches!(self.mode, ViewMode::Board)
5113 && self.focus == FocusPane::Detail =>
5114 {
5115 self.scroll_board_detail(self.list_page_step() as isize);
5116 }
5117 KeyCode::Char('b')
5118 if modifiers.contains(Modifiers::CTRL)
5119 && matches!(self.mode, ViewMode::Board)
5120 && self.focus == FocusPane::Detail =>
5121 {
5122 self.scroll_board_detail(-(self.list_page_step() as isize));
5123 }
5124 KeyCode::Char('f')
5125 if modifiers.contains(Modifiers::CTRL) && self.board_shortcut_focus() =>
5126 {
5127 self.move_board_row_relative(self.list_page_step() as isize);
5128 }
5129 KeyCode::Char('b')
5130 if modifiers.contains(Modifiers::CTRL) && self.board_shortcut_focus() =>
5131 {
5132 self.move_board_row_relative(-(self.list_page_step() as isize));
5133 }
5134 KeyCode::Char('f')
5135 if modifiers.contains(Modifiers::CTRL)
5136 && !matches!(self.mode, ViewMode::Board)
5137 && self.focus == FocusPane::Detail =>
5138 {
5139 self.scroll_detail(self.list_page_step() as isize);
5140 }
5141 KeyCode::Char('b')
5142 if modifiers.contains(Modifiers::CTRL)
5143 && !matches!(self.mode, ViewMode::Board)
5144 && self.focus == FocusPane::Detail =>
5145 {
5146 self.scroll_detail(-(self.list_page_step() as isize));
5147 }
5148 KeyCode::Char('f')
5149 if modifiers.contains(Modifiers::CTRL)
5150 && !matches!(self.mode, ViewMode::Board)
5151 && self.focus == FocusPane::List =>
5152 {
5153 self.move_selection_relative(self.list_page_step() as isize);
5154 }
5155 KeyCode::Char('b')
5156 if modifiers.contains(Modifiers::CTRL)
5157 && !matches!(self.mode, ViewMode::Board)
5158 && self.focus == FocusPane::List =>
5159 {
5160 self.move_selection_relative(-(self.list_page_step() as isize));
5161 }
5162 KeyCode::Char('h')
5163 if matches!(self.mode, ViewMode::Graph) && self.focus == FocusPane::List =>
5164 {
5165 self.move_selection_relative(-1);
5166 }
5167 KeyCode::Char('h')
5168 if matches!(self.mode, ViewMode::Graph) && self.focus == FocusPane::Detail =>
5169 {
5170 self.focus = FocusPane::List;
5171 }
5172 KeyCode::Char('l')
5173 if matches!(self.mode, ViewMode::Graph) && self.focus == FocusPane::List =>
5174 {
5175 self.move_selection_relative(1);
5176 }
5177 KeyCode::Char('H')
5178 if matches!(self.mode, ViewMode::Graph) && self.focus == FocusPane::List =>
5179 {
5180 self.move_selection_relative(-10);
5181 }
5182 KeyCode::Char('L')
5183 if matches!(self.mode, ViewMode::Graph) && self.focus == FocusPane::List =>
5184 {
5185 self.move_selection_relative(10);
5186 }
5187 KeyCode::Char('h') if matches!(self.mode, ViewMode::Insights) => {
5188 self.focus = FocusPane::List;
5189 }
5190 KeyCode::Char('l') if matches!(self.mode, ViewMode::Insights) => {
5191 self.focus = FocusPane::Detail;
5192 }
5193 KeyCode::Char('h') if matches!(self.mode, ViewMode::Main) => {
5194 self.toggle_history_mode();
5195 }
5196 KeyCode::Char('h')
5197 if matches!(self.mode, ViewMode::History) && !self.history_file_tree_focus =>
5198 {
5199 self.toggle_history_mode();
5200 }
5201 KeyCode::Char('c')
5202 if matches!(self.mode, ViewMode::History) && !self.history_file_tree_focus =>
5203 {
5204 self.cycle_history_confidence();
5205 }
5206 KeyCode::Char('v')
5207 if matches!(self.mode, ViewMode::History) && !self.history_file_tree_focus =>
5208 {
5209 self.toggle_history_view_mode();
5210 }
5211 KeyCode::Char('s') if matches!(self.mode, ViewMode::Main) => self.cycle_list_sort(),
5212 KeyCode::Char('m') if matches!(self.mode, ViewMode::Insights) => {
5213 self.toggle_insights_heatmap();
5214 }
5215 KeyCode::Char('y')
5216 if matches!(self.mode, ViewMode::History) && !self.history_file_tree_focus =>
5217 {
5218 self.history_copy_to_clipboard();
5219 }
5220 KeyCode::Char('y') if self.should_copy_selected_issue_external_ref() => {
5221 self.copy_selected_issue_external_ref();
5222 }
5223 KeyCode::Char('o')
5224 if matches!(self.mode, ViewMode::History) && !self.history_file_tree_focus =>
5225 {
5226 self.history_open_in_browser();
5227 }
5228 KeyCode::Char('o') if self.should_open_selected_issue_external_ref() => {
5229 self.open_selected_issue_external_ref();
5230 }
5231 KeyCode::Char('f' | 'F') if matches!(self.mode, ViewMode::History) => {
5232 self.toggle_history_file_tree();
5233 }
5234 KeyCode::Char('j') | KeyCode::Down
5236 if matches!(self.mode, ViewMode::History) && self.history_file_tree_focus =>
5237 {
5238 self.move_file_tree_cursor_relative(1);
5239 }
5240 KeyCode::Char('k') | KeyCode::Up
5241 if matches!(self.mode, ViewMode::History) && self.history_file_tree_focus =>
5242 {
5243 self.move_file_tree_cursor_relative(-1);
5244 }
5245 KeyCode::Enter
5246 if matches!(self.mode, ViewMode::History) && self.history_file_tree_focus =>
5247 {
5248 self.file_tree_toggle_or_filter();
5249 }
5250 KeyCode::Char('o') => self.set_list_filter(ListFilter::Open),
5251 KeyCode::Char('I') => self.set_list_filter(ListFilter::InProgress),
5252 KeyCode::Char('B') => self.set_list_filter(ListFilter::Blocked),
5253 KeyCode::Char('c') => self.set_list_filter(ListFilter::Closed),
5254 KeyCode::Char('r') => self.set_list_filter(ListFilter::Ready),
5255 KeyCode::Char('a') if self.should_clear_filter_with_all_shortcut() => {
5256 self.set_list_filter(ListFilter::All);
5257 }
5258 KeyCode::Char('j') | KeyCode::Down
5259 if matches!(self.mode, ViewMode::History)
5260 && matches!(self.history_view_mode, HistoryViewMode::Git)
5261 && self.focus == FocusPane::List =>
5262 {
5263 self.move_history_cursor_relative(1);
5264 }
5265 KeyCode::Char('k') | KeyCode::Up
5266 if matches!(self.mode, ViewMode::History)
5267 && matches!(self.history_view_mode, HistoryViewMode::Git)
5268 && self.focus == FocusPane::List =>
5269 {
5270 self.move_history_cursor_relative(-1);
5271 }
5272 KeyCode::PageUp
5273 if matches!(self.mode, ViewMode::History)
5274 && matches!(self.history_view_mode, HistoryViewMode::Git)
5275 && self.focus == FocusPane::List =>
5276 {
5277 self.move_history_cursor_relative(-10);
5278 }
5279 KeyCode::PageDown
5280 if matches!(self.mode, ViewMode::History)
5281 && matches!(self.history_view_mode, HistoryViewMode::Git)
5282 && self.focus == FocusPane::List =>
5283 {
5284 self.move_history_cursor_relative(10);
5285 }
5286 KeyCode::Char('J')
5287 if matches!(self.mode, ViewMode::History)
5288 && matches!(self.history_view_mode, HistoryViewMode::Git) =>
5289 {
5290 self.move_history_related_bead_relative(1);
5291 }
5292 KeyCode::Char('K')
5293 if matches!(self.mode, ViewMode::History)
5294 && matches!(self.history_view_mode, HistoryViewMode::Git) =>
5295 {
5296 self.move_history_related_bead_relative(-1);
5297 }
5298 KeyCode::Char('j') | KeyCode::Down
5299 if matches!(self.mode, ViewMode::History)
5300 && matches!(self.history_view_mode, HistoryViewMode::Git)
5301 && matches!(self.focus, FocusPane::Middle | FocusPane::Detail) =>
5302 {
5303 self.move_history_related_bead_relative(1);
5304 }
5305 KeyCode::Char('k') | KeyCode::Up
5306 if matches!(self.mode, ViewMode::History)
5307 && matches!(self.history_view_mode, HistoryViewMode::Git)
5308 && matches!(self.focus, FocusPane::Middle | FocusPane::Detail) =>
5309 {
5310 self.move_history_related_bead_relative(-1);
5311 }
5312 KeyCode::Char('J')
5313 if matches!(self.mode, ViewMode::History)
5314 && matches!(self.history_view_mode, HistoryViewMode::Bead) =>
5315 {
5316 self.move_history_bead_commit_relative(1);
5317 }
5318 KeyCode::Char('K')
5319 if matches!(self.mode, ViewMode::History)
5320 && matches!(self.history_view_mode, HistoryViewMode::Bead) =>
5321 {
5322 self.move_history_bead_commit_relative(-1);
5323 }
5324 KeyCode::Char('j') | KeyCode::Down
5325 if matches!(self.mode, ViewMode::History)
5326 && matches!(self.history_view_mode, HistoryViewMode::Bead)
5327 && matches!(self.focus, FocusPane::Middle | FocusPane::Detail) =>
5328 {
5329 self.move_history_bead_commit_relative(1);
5330 }
5331 KeyCode::Char('k') | KeyCode::Up
5332 if matches!(self.mode, ViewMode::History)
5333 && matches!(self.history_view_mode, HistoryViewMode::Bead)
5334 && matches!(self.focus, FocusPane::Middle | FocusPane::Detail) =>
5335 {
5336 self.move_history_bead_commit_relative(-1);
5337 }
5338 KeyCode::Char('J')
5340 if matches!(
5341 self.mode,
5342 ViewMode::Board | ViewMode::Graph | ViewMode::Insights
5343 ) =>
5344 {
5345 self.move_detail_dep_relative(1);
5346 }
5347 KeyCode::Char('K')
5348 if matches!(
5349 self.mode,
5350 ViewMode::Board | ViewMode::Graph | ViewMode::Insights
5351 ) =>
5352 {
5353 self.move_detail_dep_relative(-1);
5354 }
5355 KeyCode::Char('j') | KeyCode::Down
5356 if matches!(self.mode, ViewMode::Graph | ViewMode::Insights)
5357 && self.focus == FocusPane::Detail
5358 && !self.detail_dep_list().is_empty() =>
5359 {
5360 self.move_detail_dep_relative(1);
5361 }
5362 KeyCode::Char('k') | KeyCode::Up
5363 if matches!(self.mode, ViewMode::Graph | ViewMode::Insights)
5364 && self.focus == FocusPane::Detail
5365 && !self.detail_dep_list().is_empty() =>
5366 {
5367 self.move_detail_dep_relative(-1);
5368 }
5369 KeyCode::Home
5370 if matches!(self.mode, ViewMode::History)
5371 && matches!(self.history_view_mode, HistoryViewMode::Git)
5372 && self.focus == FocusPane::List =>
5373 {
5374 self.history_event_cursor = 0;
5375 self.history_related_bead_cursor = 0;
5376 }
5377 KeyCode::End | KeyCode::Char('G')
5378 if matches!(self.mode, ViewMode::History)
5379 && matches!(self.history_view_mode, HistoryViewMode::Git)
5380 && self.focus == FocusPane::List =>
5381 {
5382 self.select_last_history_event();
5383 }
5384 KeyCode::Char('j') | KeyCode::Down if self.focus == FocusPane::List => {
5385 self.move_selection_relative(1);
5386 }
5387 KeyCode::Char('k') | KeyCode::Up if self.focus == FocusPane::List => {
5388 self.move_selection_relative(-1);
5389 }
5390 KeyCode::PageUp if self.focus == FocusPane::List => {
5391 self.move_selection_relative(-(self.list_page_step() as isize));
5392 }
5393 KeyCode::PageDown if self.focus == FocusPane::List => {
5394 self.move_selection_relative(self.list_page_step() as isize);
5395 }
5396 KeyCode::Home | KeyCode::Char('0') if self.board_shortcut_focus() => {
5397 self.select_edge_in_current_board_lane(false);
5398 }
5399 KeyCode::End | KeyCode::Char('G' | '$') if self.board_shortcut_focus() => {
5400 self.select_edge_in_current_board_lane(true);
5401 }
5402 KeyCode::Home if self.focus == FocusPane::List => {
5403 self.select_first_visible();
5404 }
5405 KeyCode::End | KeyCode::Char('G') if self.focus == FocusPane::List => {
5406 self.select_last_visible();
5407 }
5408 KeyCode::Char('1') if self.board_shortcut_focus() => {
5409 self.select_first_in_board_lane(1);
5410 }
5411 KeyCode::Char('2') if self.board_shortcut_focus() => {
5412 self.select_first_in_board_lane(2);
5413 }
5414 KeyCode::Char('3') if self.board_shortcut_focus() => {
5415 self.select_first_in_board_lane(3);
5416 }
5417 KeyCode::Char('4') if self.board_shortcut_focus() => {
5418 self.select_first_in_board_lane(4);
5419 }
5420 KeyCode::Char('H') if self.board_shortcut_focus() => {
5421 self.select_first_in_non_empty_board_lane();
5422 }
5423 KeyCode::Char('L') if self.board_shortcut_focus() => {
5424 self.select_last_in_non_empty_board_lane();
5425 }
5426 KeyCode::Char('s') if matches!(self.mode, ViewMode::Board) => {
5427 self.cycle_board_grouping();
5428 }
5429 KeyCode::Char('e') if matches!(self.mode, ViewMode::Board) => {
5430 self.toggle_board_empty_visibility();
5431 }
5432 KeyCode::Char('s')
5433 if matches!(self.mode, ViewMode::Insights) && self.insights_heatmap.is_none() =>
5434 {
5435 self.insights_panel = self.insights_panel.next();
5436 self.reselect_insights_panel_context();
5437 }
5438 KeyCode::Char('S')
5439 if matches!(self.mode, ViewMode::Insights) && self.insights_heatmap.is_none() =>
5440 {
5441 self.insights_panel = self.insights_panel.prev();
5442 self.reselect_insights_panel_context();
5443 }
5444 KeyCode::Char('e') if matches!(self.mode, ViewMode::Insights) => {
5445 self.toggle_insights_explanations();
5446 }
5447 KeyCode::Char('x') if matches!(self.mode, ViewMode::Insights) => {
5448 self.toggle_insights_calc_proof();
5449 }
5450 KeyCode::Char('1') => {
5451 self.mode = ViewMode::Main;
5452 self.focus = FocusPane::List;
5453 self.sync_ranked_list_context();
5454 }
5455 KeyCode::Char('b') => {
5456 self.mode = if matches!(self.mode, ViewMode::Board) {
5457 ViewMode::Main
5458 } else {
5459 ViewMode::Board
5460 };
5461 self.focus = FocusPane::List;
5462 }
5463 KeyCode::Char('i') => {
5464 let entering_insights = !matches!(self.mode, ViewMode::Insights);
5465 let previous_mode = self.mode;
5466 let previous_selected = self.selected;
5467 self.mode = if entering_insights {
5468 ViewMode::Insights
5469 } else {
5470 ViewMode::Main
5471 };
5472 self.focus = FocusPane::List;
5473 if entering_insights {
5474 if matches!(previous_mode, ViewMode::Main) {
5475 self.reselect_ranked_list_context();
5476 } else {
5477 self.sync_insights_heatmap_selection();
5478 if self.insights_heatmap.is_none() {
5479 self.set_selected_index(previous_selected);
5480 }
5481 }
5482 } else {
5483 self.sync_ranked_list_context();
5484 }
5485 }
5486 KeyCode::Char('g') if matches!(self.mode, ViewMode::History) => {
5487 let saved = self.mode;
5488 if matches!(self.history_view_mode, HistoryViewMode::Git)
5489 && let Some(bead_id) = self
5490 .selected_history_event()
5491 .map(|event| event.issue_id)
5492 .or_else(|| self.selected_history_git_related_bead_id())
5493 {
5494 self.select_issue_by_id(&bead_id);
5495 }
5496 self.mode = ViewMode::Graph;
5497 self.focus = FocusPane::List;
5498 self.sync_ranked_list_context();
5499 self.pending_g = true;
5500 self.g_pre_toggle_mode = Some(saved);
5501 }
5502 KeyCode::Char('g') => {
5503 let saved = self.mode;
5504 let entering_graph = !matches!(self.mode, ViewMode::Graph);
5505 let previous_mode = self.mode;
5506 let previous_selected = self.selected;
5507 self.mode = if entering_graph {
5508 ViewMode::Graph
5509 } else {
5510 ViewMode::Main
5511 };
5512 self.focus = FocusPane::List;
5513 if entering_graph {
5514 if matches!(previous_mode, ViewMode::Main) {
5515 self.reselect_ranked_list_context();
5516 } else if !self.graph_search_query.trim().is_empty() {
5517 self.select_current_graph_search_match();
5518 } else {
5519 self.set_selected_index(previous_selected);
5520 self.sync_insights_heatmap_selection();
5521 }
5522 } else {
5523 self.sync_ranked_list_context();
5524 }
5525 self.pending_g = true;
5526 self.g_pre_toggle_mode = Some(saved);
5527 }
5528 KeyCode::Char('a') => {
5529 self.mode = if matches!(self.mode, ViewMode::Actionable) {
5530 ViewMode::Main
5531 } else {
5532 self.compute_actionable_plan();
5533 ViewMode::Actionable
5534 };
5535 self.detail_scroll_offset = 0;
5536 self.focus = FocusPane::List;
5537 }
5538 KeyCode::Char('!') => {
5539 self.mode = if matches!(self.mode, ViewMode::Attention) {
5540 ViewMode::Main
5541 } else {
5542 self.compute_attention();
5543 ViewMode::Attention
5544 };
5545 self.focus = FocusPane::List;
5546 }
5547 KeyCode::Char('T') => {
5548 self.toggle_tree_mode();
5549 self.focus = FocusPane::List;
5550 }
5551 KeyCode::Char('t') if modifiers.contains(Modifiers::CTRL) => {
5552 self.open_tutorial();
5553 }
5554 KeyCode::Char('t') => {
5555 self.toggle_time_travel_mode();
5556 }
5557 KeyCode::Char('[') => {
5558 self.toggle_label_dashboard();
5559 self.focus = FocusPane::List;
5560 }
5561 KeyCode::Char(']') => {
5562 self.toggle_flow_matrix();
5563 self.focus = FocusPane::List;
5564 }
5565 KeyCode::Char('S') if !matches!(self.mode, ViewMode::Insights) => {
5566 self.toggle_sprint_mode();
5567 self.focus = FocusPane::List;
5568 }
5569 KeyCode::Char('\'') => {
5570 self.open_recipe_picker();
5571 }
5572 KeyCode::Char('P') => {
5573 self.open_pages_wizard();
5574 }
5575 KeyCode::Char('L') => {
5576 self.open_label_picker();
5577 }
5578 KeyCode::Char('w') => {
5579 self.open_repo_picker();
5580 }
5581 KeyCode::Char('p') if matches!(self.mode, ViewMode::Main) => {
5582 self.priority_hints_visible = !self.priority_hints_visible;
5583 }
5584 KeyCode::Char('C') => {
5585 self.copy_selected_issue_id();
5586 }
5587 KeyCode::Char('x') if matches!(self.mode, ViewMode::Main) => {
5588 self.export_selected_issue_markdown();
5589 }
5590 KeyCode::Char('O') => {
5591 self.open_selected_in_editor();
5592 }
5593 _ => {}
5594 }
5595
5596 if !matches!(self.mode, ViewMode::History) {
5597 self.mode_before_history = self.mode;
5598 }
5599
5600 Cmd::None
5601 }
5602
5603 fn toggle_history_mode(&mut self) {
5604 if matches!(self.mode, ViewMode::History) {
5605 self.mode = self.mode_before_history;
5606 self.focus = FocusPane::List;
5607 return;
5608 }
5609
5610 self.mode_before_history = self.mode;
5611 self.mode = ViewMode::History;
5612 self.history_view_mode = HistoryViewMode::Bead;
5613 self.history_event_cursor = 0;
5614 self.history_related_bead_cursor = 0;
5615 self.history_bead_commit_cursor = 0;
5616 self.history_search_active = false;
5617 self.history_search_query.clear();
5618 self.history_search_match_cursor = 0;
5619 self.history_search_mode = HistorySearchMode::All;
5620 self.history_show_file_tree = false;
5621 self.history_file_tree_cursor = 0;
5622 self.history_file_tree_filter = None;
5623 self.history_file_tree_focus = false;
5624 self.history_status_msg.clear();
5625 self.focus = FocusPane::List;
5626 self.ensure_git_history_loaded();
5627 }
5628
5629 fn cycle_history_confidence(&mut self) {
5630 self.history_confidence_index =
5631 (self.history_confidence_index + 1) % HISTORY_CONFIDENCE_STEPS.len();
5632 self.history_related_bead_cursor = 0;
5633 self.history_bead_commit_cursor = 0;
5634
5635 if matches!(self.history_view_mode, HistoryViewMode::Git) {
5636 let visible = self.history_git_visible_commit_indices();
5637 self.history_event_cursor = self
5638 .history_event_cursor
5639 .min(visible.len().saturating_sub(1));
5640 } else {
5641 let visible = self.history_visible_issue_indices();
5642 if !visible.contains(&self.selected)
5643 && let Some(&first_visible) = visible.first()
5644 {
5645 self.set_selected_index(first_visible);
5646 }
5647 }
5648
5649 let flat = self.history_flat_file_list();
5650 self.history_file_tree_cursor = self
5651 .history_file_tree_cursor
5652 .min(flat.len().saturating_sub(1));
5653 self.refresh_history_search_selection();
5654 }
5655
5656 fn history_min_confidence(&self) -> f64 {
5657 HISTORY_CONFIDENCE_STEPS
5658 .get(self.history_confidence_index)
5659 .copied()
5660 .unwrap_or(0.0)
5661 }
5662
5663 fn toggle_history_view_mode(&mut self) {
5664 self.history_view_mode = self.history_view_mode.toggle();
5665 self.history_event_cursor = 0;
5666 self.history_related_bead_cursor = 0;
5667 self.history_bead_commit_cursor = 0;
5668 self.focus = FocusPane::List;
5669 self.ensure_git_history_loaded();
5670 self.refresh_history_search_selection();
5671 }
5672
5673 fn jump_from_history_bead_commit_to_git(&mut self) {
5674 let Some(commit) = self.selected_history_bead_commit() else {
5675 self.history_status_msg = "No correlated commit selected".to_string();
5676 return;
5677 };
5678
5679 self.ensure_git_history_loaded();
5680 self.history_view_mode = HistoryViewMode::Git;
5681 self.focus = FocusPane::List;
5682 self.history_related_bead_cursor = 0;
5683 self.history_bead_commit_cursor = 0;
5684
5685 let visible = self.history_git_visible_commit_indices();
5686 let Some(target_slot) = visible.iter().position(|index| {
5687 self.history_git_cache
5688 .as_ref()
5689 .and_then(|cache| cache.commits.get(*index))
5690 .is_some_and(|entry| entry.sha == commit.sha)
5691 }) else {
5692 self.history_status_msg =
5693 format!("Commit {} not visible in git view", commit.short_sha);
5694 return;
5695 };
5696
5697 self.history_event_cursor = target_slot;
5698 self.history_status_msg = format!("Backtraced to commit {}", commit.short_sha);
5699 }
5700
5701 fn start_history_search(&mut self) {
5702 if !matches!(self.mode, ViewMode::History) || self.focus != FocusPane::List {
5703 return;
5704 }
5705
5706 self.history_search_active = true;
5707 self.history_search_query.clear();
5708 self.history_search_match_cursor = 0;
5709 self.history_search_mode = HistorySearchMode::All;
5710 self.history_event_cursor = 0;
5711 self.history_related_bead_cursor = 0;
5712 self.history_bead_commit_cursor = 0;
5713 }
5714
5715 fn finish_history_search(&mut self) {
5716 self.history_search_active = false;
5717 }
5718
5719 fn cancel_history_search(&mut self) {
5720 self.history_search_active = false;
5721 self.history_search_match_cursor = 0;
5722 self.history_search_query.clear();
5723 self.history_event_cursor = 0;
5724 self.history_related_bead_cursor = 0;
5725 self.history_bead_commit_cursor = 0;
5726 }
5727
5728 fn open_tutorial(&mut self) {
5729 self.modal_overlay = Some(ModalOverlay::Tutorial);
5730 }
5731
5732 fn open_confirm_with_resume(
5733 &mut self,
5734 title: impl Into<String>,
5735 message: impl Into<String>,
5736 resume_overlay: Option<ModalOverlay>,
5737 ) {
5738 self.modal_confirm_result = None;
5739 self.modal_overlay = Some(ModalOverlay::Confirm {
5740 title: title.into(),
5741 message: message.into(),
5742 resume_overlay: resume_overlay.map(Box::new),
5743 });
5744 }
5745
5746 fn open_pages_wizard(&mut self) {
5747 self.modal_overlay = Some(ModalOverlay::PagesWizard(PagesWizardState::new()));
5748 }
5749
5750 fn toggle_history_file_tree(&mut self) {
5751 self.history_show_file_tree = !self.history_show_file_tree;
5752 if self.history_show_file_tree {
5753 self.history_status_msg = "File tree: j/k navigate, Enter filter, Esc close".into();
5754 } else {
5755 self.history_file_tree_focus = false;
5756 self.history_status_msg = "File tree hidden".into();
5757 }
5758 }
5759
5760 fn move_file_tree_cursor_relative(&mut self, delta: isize) {
5761 let flat = self.history_flat_file_list();
5762 if flat.is_empty() {
5763 return;
5764 }
5765 let len = flat.len();
5766 let cur = self.history_file_tree_cursor.min(len.saturating_sub(1));
5767 self.history_file_tree_cursor = if delta > 0 {
5768 cur.saturating_add(delta.unsigned_abs())
5769 .min(len.saturating_sub(1))
5770 } else {
5771 cur.saturating_sub(delta.unsigned_abs())
5772 };
5773 }
5774
5775 fn file_tree_toggle_or_filter(&mut self) {
5776 let flat = self.history_flat_file_list();
5777 let cursor = self
5778 .history_file_tree_cursor
5779 .min(flat.len().saturating_sub(1));
5780 if let Some(entry) = flat.get(cursor) {
5781 let path = entry.path.clone();
5782 if self.history_file_tree_filter.as_deref() == Some(&path) {
5783 self.history_file_tree_filter = None;
5784 self.history_status_msg = "Filter cleared".into();
5785 } else {
5786 self.history_file_tree_filter = Some(path.clone());
5787 self.history_status_msg = format!("Filtered to: {path}");
5788 }
5789 self.history_event_cursor = 0;
5790 self.history_bead_commit_cursor = 0;
5791 if matches!(self.history_view_mode, HistoryViewMode::Bead) {
5792 let visible = self.history_visible_issue_indices();
5793 if !visible.contains(&self.selected)
5794 && let Some(&first_visible) = visible.first()
5795 {
5796 self.set_selected_index(first_visible);
5797 }
5798 }
5799 }
5800 }
5801
5802 fn history_path_matches_file_filter(&self, path: &str) -> bool {
5803 self.history_file_tree_filter
5804 .as_deref()
5805 .is_none_or(|filter| path == filter || path.starts_with(&format!("{filter}/")))
5806 }
5807
5808 fn history_filtered_bead_commits<'a>(&'a self, issue_id: &str) -> Vec<&'a HistoryCommitCompat> {
5809 let min_confidence = self.history_min_confidence();
5810 self.history_git_cache
5811 .as_ref()
5812 .and_then(|cache| cache.histories.get(issue_id))
5813 .and_then(|history| history.commits.as_deref())
5814 .map(|commits| {
5815 commits
5816 .iter()
5817 .filter(|commit| {
5818 commit.confidence >= min_confidence
5819 && commit
5820 .files
5821 .iter()
5822 .any(|file| self.history_path_matches_file_filter(&file.path))
5823 })
5824 .collect()
5825 })
5826 .unwrap_or_default()
5827 }
5828
5829 fn history_file_tree_nodes(&self) -> Vec<FileTreeNode> {
5830 let Some(cache) = &self.history_git_cache else {
5831 return Vec::new();
5832 };
5833
5834 let mut file_counts: BTreeMap<String, usize> = BTreeMap::new();
5835 for commit in &cache.commits {
5836 if self
5837 .history_git_related_beads_for_commit(&commit.sha)
5838 .is_empty()
5839 {
5840 continue;
5841 }
5842 for file in &commit.files {
5843 *file_counts.entry(file.path.clone()).or_default() += 1;
5844 }
5845 }
5846
5847 fn insert_path(
5848 nodes: &mut Vec<FileTreeNode>,
5849 parts: &[&str],
5850 level: usize,
5851 prefix: &str,
5852 count: usize,
5853 ) {
5854 let Some((name, rest)) = parts.split_first() else {
5855 return;
5856 };
5857
5858 let path = if prefix.is_empty() {
5859 (*name).to_string()
5860 } else {
5861 format!("{prefix}/{name}")
5862 };
5863 let is_dir = !rest.is_empty();
5864 let index = nodes
5865 .iter()
5866 .position(|node| node.path == path)
5867 .unwrap_or_else(|| {
5868 nodes.push(FileTreeNode {
5869 name: (*name).to_string(),
5870 path: path.clone(),
5871 is_dir,
5872 change_count: 0,
5873 expanded: true,
5874 level,
5875 children: Vec::new(),
5876 });
5877 nodes.len() - 1
5878 });
5879
5880 nodes[index].change_count += count;
5881 if is_dir {
5882 insert_path(&mut nodes[index].children, rest, level + 1, &path, count);
5883 }
5884 }
5885
5886 fn sort_nodes(nodes: &mut [FileTreeNode]) {
5887 for node in nodes.iter_mut() {
5888 sort_nodes(&mut node.children);
5889 }
5890 nodes.sort_by(|left, right| {
5891 right
5892 .is_dir
5893 .cmp(&left.is_dir)
5894 .then_with(|| left.name.cmp(&right.name))
5895 });
5896 }
5897
5898 let mut roots = Vec::new();
5899 for (path, count) in file_counts {
5900 let parts = path.split('/').collect::<Vec<_>>();
5901 insert_path(&mut roots, &parts, 0, "", count);
5902 }
5903 sort_nodes(&mut roots);
5904 roots
5905 }
5906
5907 fn history_flat_file_list(&self) -> Vec<FlatFileEntry> {
5908 self.history_file_tree_nodes()
5909 .iter()
5910 .flat_map(FileTreeNode::flatten_visible)
5911 .collect()
5912 }
5913
5914 fn history_copy_to_clipboard(&mut self) {
5916 let text = if matches!(self.history_view_mode, HistoryViewMode::Git) {
5917 self.selected_history_git_commit_sha()
5918 } else {
5919 self.selected_issue().map(|issue| issue.id.clone())
5920 };
5921
5922 if let Some(text) = text {
5923 if copy_text_to_clipboard(&text) {
5924 let short = if text.len() > 7 { &text[..7] } else { &text };
5925 self.history_status_msg = format!("Copied {short} to clipboard");
5926 } else {
5927 self.history_status_msg = "Clipboard not available".into();
5928 }
5929 } else {
5930 self.history_status_msg = "No item selected".into();
5931 }
5932 }
5933
5934 fn selected_history_git_commit_sha(&self) -> Option<String> {
5935 let cache = self.history_git_cache.as_ref()?;
5936 let commit = cache.commits.get(self.history_event_cursor)?;
5937 Some(commit.sha.clone())
5938 }
5939
5940 fn history_selected_commit_sha(&self) -> Option<String> {
5941 if matches!(self.history_view_mode, HistoryViewMode::Git) {
5942 self.selected_history_git_commit_sha()
5943 } else {
5944 self.selected_history_bead_commit().map(|commit| commit.sha)
5945 }
5946 }
5947
5948 fn history_selected_commit_url(&self) -> Option<String> {
5949 self.history_selected_commit_sha()
5950 .and_then(|sha| self.history_commit_url_for_sha(&sha))
5951 }
5952
5953 fn history_commit_for_bead(&self, bead_id: &str, sha: &str) -> Option<HistoryCommitCompat> {
5954 self.history_git_cache
5955 .as_ref()
5956 .and_then(|cache| cache.histories.get(bead_id))
5957 .and_then(|history| history.commits.as_deref())
5958 .and_then(|commits| commits.iter().find(|commit| commit.sha == sha))
5959 .cloned()
5960 }
5961
5962 fn selected_history_git_bead_commit(&self) -> Option<HistoryCommitCompat> {
5963 let commit = self.selected_history_git_commit()?;
5964 let bead_id = self.selected_history_git_related_bead_id()?;
5965 self.history_commit_for_bead(&bead_id, &commit.sha)
5966 }
5967
5968 fn history_commit_url_for_sha(&self, sha: &str) -> Option<String> {
5969 let repo_root = self
5970 .repo_root
5971 .clone()
5972 .or_else(|| std::env::current_dir().ok())?;
5973 let remote_url = std::process::Command::new("git")
5974 .args(["config", "--get", "remote.origin.url"])
5975 .current_dir(&repo_root)
5976 .output()
5977 .ok()
5978 .and_then(|output| {
5979 output
5980 .status
5981 .success()
5982 .then(|| String::from_utf8(output.stdout).ok())
5983 .flatten()
5984 })
5985 .map(|s| s.trim().to_string())?;
5986 remote_to_commit_url(&remote_url, sha)
5987 }
5988
5989 fn history_open_in_browser(&mut self) {
5991 let sha = self.history_selected_commit_sha();
5992
5993 let Some(sha) = sha else {
5994 self.history_status_msg = "No commit selected".into();
5995 return;
5996 };
5997
5998 let Some(url) = self.history_commit_url_for_sha(&sha) else {
5999 self.history_status_msg = "Cannot build commit URL from remote".into();
6000 return;
6001 };
6002
6003 if open_url_in_browser(&url) {
6004 let short = if sha.len() > 7 { &sha[..7] } else { &sha };
6005 self.history_status_msg = format!("Opened {short} in browser");
6006 } else {
6007 self.history_status_msg = "Could not open browser".into();
6008 }
6009 }
6010
6011 fn history_copy_commit_url(&mut self) {
6012 let sha = self.history_selected_commit_sha();
6013
6014 let Some(sha) = sha else {
6015 self.history_status_msg = "No commit selected".into();
6016 return;
6017 };
6018
6019 let Some(url) = self.history_commit_url_for_sha(&sha) else {
6020 self.history_status_msg = "Cannot build commit URL from remote".into();
6021 return;
6022 };
6023
6024 if copy_text_to_clipboard(&url) {
6025 let short = if sha.len() > 7 { &sha[..7] } else { &sha };
6026 self.history_status_msg = format!("Copied {short} commit URL");
6027 } else {
6028 self.history_status_msg = "Clipboard not available".into();
6029 }
6030 }
6031
6032 fn refresh_history_search_selection(&mut self) {
6033 if self.history_search_query.trim().is_empty() {
6034 return;
6035 }
6036
6037 if matches!(self.history_view_mode, HistoryViewMode::Git) {
6038 self.history_event_cursor = 0;
6039 self.history_related_bead_cursor = 0;
6040 return;
6041 }
6042
6043 let visible = self.history_visible_issue_indices();
6044 if let Some(index) = visible.first().copied() {
6045 self.set_selected_index(index);
6046 self.focus = FocusPane::List;
6047 self.history_bead_commit_cursor = 0;
6048 }
6049 }
6050
6051 fn history_search_matches(&self) -> Vec<usize> {
6055 let query = self.history_search_query.trim().to_ascii_lowercase();
6056 if query.is_empty() {
6057 return Vec::new();
6058 }
6059
6060 if matches!(self.history_view_mode, HistoryViewMode::Git) {
6061 self.history_git_visible_commit_indices()
6062 } else {
6063 self.history_visible_issue_indices()
6064 }
6065 }
6066
6067 fn move_history_search_match_relative(&mut self, delta: isize) {
6068 let matches = self.history_search_matches();
6069 if matches.is_empty() || delta == 0 {
6070 return;
6071 }
6072
6073 let len = matches.len();
6074 let current = self.history_search_match_cursor.min(len.saturating_sub(1));
6075 let step = delta.unsigned_abs() % len;
6076 let next = if delta > 0 {
6077 (current + step) % len
6078 } else {
6079 (current + len - step) % len
6080 };
6081
6082 self.history_search_match_cursor = next;
6083
6084 if matches!(self.history_view_mode, HistoryViewMode::Git) {
6085 self.history_event_cursor = matches[next];
6086 self.history_related_bead_cursor = 0;
6087 } else {
6088 self.set_selected_index(matches[next]);
6089 self.history_bead_commit_cursor = 0;
6090 }
6091 }
6092
6093 fn ensure_git_history_loaded(&mut self) {
6094 if self.history_git_cache.is_some() {
6095 return;
6096 }
6097
6098 let repo_root = self
6099 .repo_root
6100 .clone()
6101 .or_else(|| std::env::current_dir().ok());
6102 let Some(repo_root) = repo_root else {
6103 return;
6104 };
6105
6106 let commits = load_git_commits(&repo_root, 500, None).unwrap_or_default();
6107 let mut histories = self
6108 .analyzer
6109 .issues
6110 .iter()
6111 .map(|issue| {
6112 (
6113 issue.id.clone(),
6114 HistoryBeadCompat {
6115 bead_id: issue.id.clone(),
6116 title: issue.title.clone(),
6117 status: issue.status.clone(),
6118 events: Vec::new(),
6119 milestones: HistoryMilestonesCompat::default(),
6120 commits: None,
6121 cycle_time: None,
6122 last_author: String::new(),
6123 },
6124 )
6125 })
6126 .collect::<BTreeMap<_, _>>();
6127
6128 let mut commit_index = BTreeMap::<String, Vec<String>>::new();
6129 let mut method_distribution = BTreeMap::<String, usize>::new();
6130 let workspace_aliases = build_workspace_id_aliases(&self.analyzer.issues);
6131
6132 correlate_histories_with_git_aliases(
6133 &repo_root,
6134 &commits,
6135 &mut histories,
6136 &mut commit_index,
6137 &mut method_distribution,
6138 &workspace_aliases,
6139 );
6140
6141 finalize_history_entries(&mut histories);
6142
6143 let mut commit_bead_confidence = BTreeMap::<String, Vec<(String, f64)>>::new();
6144 for history in histories.values() {
6145 for commit in history.commits.as_deref().unwrap_or_default() {
6146 commit_bead_confidence
6147 .entry(commit.sha.clone())
6148 .or_default()
6149 .push((history.bead_id.clone(), commit.confidence));
6150 }
6151 }
6152 for pairs in commit_bead_confidence.values_mut() {
6153 pairs.sort_by(|left, right| left.0.cmp(&right.0));
6154 }
6155
6156 self.history_git_cache = Some(HistoryGitCache {
6157 commits,
6158 histories,
6159 commit_bead_confidence,
6160 });
6161 }
6162
6163 fn history_git_visible_commit_indices(&self) -> Vec<usize> {
6164 let Some(cache) = &self.history_git_cache else {
6165 return Vec::new();
6166 };
6167
6168 let min_confidence = self.history_min_confidence();
6169 let query = self.history_search_query.trim().to_ascii_lowercase();
6170
6171 cache
6172 .commits
6173 .iter()
6174 .enumerate()
6175 .filter_map(|(index, commit)| {
6176 let related = self.history_git_related_beads_for_commit(&commit.sha);
6177 if related.is_empty() {
6178 return None;
6179 }
6180 if !commit
6181 .files
6182 .iter()
6183 .any(|file| self.history_path_matches_file_filter(&file.path))
6184 {
6185 return None;
6186 }
6187
6188 if query.is_empty() {
6189 return Some(index);
6190 }
6191
6192 let matches = match self.history_search_mode {
6193 HistorySearchMode::Sha => {
6194 commit.sha.to_ascii_lowercase().starts_with(&query)
6195 || commit.short_sha.to_ascii_lowercase().starts_with(&query)
6196 }
6197 HistorySearchMode::Commit => {
6198 commit.message.to_ascii_lowercase().contains(&query)
6199 }
6200 HistorySearchMode::Author => {
6201 commit.author.to_ascii_lowercase().contains(&query)
6202 || commit.author_email.to_ascii_lowercase().contains(&query)
6203 }
6204 HistorySearchMode::Bead => related
6205 .iter()
6206 .any(|id| id.to_ascii_lowercase().contains(&query)),
6207 HistorySearchMode::All => {
6208 let sha = commit.sha.to_ascii_lowercase();
6209 let short_sha = commit.short_sha.to_ascii_lowercase();
6210 let message = commit.message.to_ascii_lowercase();
6211 let author = commit.author.to_ascii_lowercase();
6212 let author_email = commit.author_email.to_ascii_lowercase();
6213 let related_match = related
6214 .iter()
6215 .any(|id| id.to_ascii_lowercase().contains(&query));
6216 sha.contains(&query)
6217 || short_sha.contains(&query)
6218 || message.contains(&query)
6219 || author.contains(&query)
6220 || author_email.contains(&query)
6221 || commit.timestamp.to_ascii_lowercase().contains(&query)
6222 || related_match
6223 }
6224 };
6225
6226 matches.then_some(index)
6227 })
6228 .filter(|index| {
6229 let commit = cache.commits.get(*index);
6230 commit.is_some_and(|commit| {
6231 self.history_git_related_beads_for_commit(&commit.sha)
6232 .iter()
6233 .any(|bead_id| {
6234 cache.histories.get(bead_id).is_some_and(|history| {
6235 history
6236 .commits
6237 .as_deref()
6238 .unwrap_or_default()
6239 .iter()
6240 .any(|entry| {
6241 entry.sha == commit.sha
6242 && entry.confidence >= min_confidence
6243 })
6244 })
6245 })
6246 })
6247 })
6248 .collect()
6249 }
6250
6251 fn selected_history_git_commit(&self) -> Option<&GitCommitRecord> {
6252 let Some(cache) = &self.history_git_cache else {
6253 return None;
6254 };
6255
6256 let visible = self.history_git_visible_commit_indices();
6257 if visible.is_empty() {
6258 return None;
6259 }
6260
6261 let slot = self
6262 .history_event_cursor
6263 .min(visible.len().saturating_sub(1));
6264 let index = visible[slot];
6265 cache.commits.get(index)
6266 }
6267
6268 fn history_git_related_beads_for_commit(&self, sha: &str) -> Vec<String> {
6269 let Some(cache) = &self.history_git_cache else {
6270 return Vec::new();
6271 };
6272
6273 let min_confidence = self.history_min_confidence();
6274 cache
6275 .commit_bead_confidence
6276 .get(sha)
6277 .into_iter()
6278 .flatten()
6279 .filter(|(_, confidence)| *confidence >= min_confidence)
6280 .map(|(bead_id, _)| bead_id.clone())
6281 .collect()
6282 }
6283
6284 fn selected_history_git_related_bead_id(&self) -> Option<String> {
6285 let commit = self.selected_history_git_commit()?;
6286 let related = self.history_git_related_beads_for_commit(&commit.sha);
6287 if related.is_empty() {
6288 return None;
6289 }
6290
6291 let slot = self
6292 .history_related_bead_cursor
6293 .min(related.len().saturating_sub(1));
6294 related.get(slot).cloned()
6295 }
6296
6297 fn move_history_cursor_relative(&mut self, delta: isize) {
6298 let commits_len = self.history_git_visible_commit_indices().len();
6299 if commits_len == 0 {
6300 self.history_event_cursor = 0;
6301 return;
6302 }
6303
6304 let max_slot = commits_len.saturating_sub(1);
6305 let next_slot = if delta >= 0 {
6306 self.history_event_cursor
6307 .saturating_add(delta.unsigned_abs())
6308 .min(max_slot)
6309 } else {
6310 self.history_event_cursor
6311 .saturating_sub(delta.unsigned_abs())
6312 };
6313 self.history_event_cursor = next_slot;
6314 self.history_related_bead_cursor = 0;
6315 }
6316
6317 fn select_last_history_event(&mut self) {
6318 let commits_len = self.history_git_visible_commit_indices().len();
6319 self.history_event_cursor = commits_len.saturating_sub(1);
6320 self.history_related_bead_cursor = 0;
6321 }
6322
6323 fn move_history_related_bead_relative(&mut self, delta: isize) {
6324 if delta == 0 {
6325 return;
6326 }
6327
6328 if self.focus == FocusPane::List {
6329 self.move_history_cursor_relative(delta);
6330 return;
6331 }
6332
6333 let Some(commit) = self.selected_history_git_commit() else {
6334 self.history_related_bead_cursor = 0;
6335 return;
6336 };
6337 let related_len = self.history_git_related_beads_for_commit(&commit.sha).len();
6338 if related_len == 0 {
6339 self.history_related_bead_cursor = 0;
6340 return;
6341 }
6342
6343 let max_slot = related_len.saturating_sub(1);
6344 let next_slot = if delta >= 0 {
6345 self.history_related_bead_cursor
6346 .saturating_add(delta.unsigned_abs())
6347 .min(max_slot)
6348 } else {
6349 self.history_related_bead_cursor
6350 .saturating_sub(delta.unsigned_abs())
6351 };
6352 self.history_related_bead_cursor = next_slot;
6353 }
6354
6355 fn move_history_bead_commit_relative(&mut self, delta: isize) {
6356 if delta == 0 {
6357 return;
6358 }
6359
6360 if self.focus == FocusPane::List {
6361 self.move_selection_relative(delta);
6362 return;
6363 }
6364
6365 let Some(issue_id) = self.selected_issue().map(|issue| issue.id.clone()) else {
6366 self.history_bead_commit_cursor = 0;
6367 return;
6368 };
6369
6370 self.ensure_git_history_loaded();
6371
6372 let commits_len = self.history_filtered_bead_commits(&issue_id).len();
6373
6374 if commits_len == 0 {
6375 self.history_bead_commit_cursor = 0;
6376 return;
6377 }
6378
6379 let max_slot = commits_len.saturating_sub(1);
6380 let next_slot = if delta >= 0 {
6381 self.history_bead_commit_cursor
6382 .saturating_add(delta.unsigned_abs())
6383 .min(max_slot)
6384 } else {
6385 self.history_bead_commit_cursor
6386 .saturating_sub(delta.unsigned_abs())
6387 };
6388 self.history_bead_commit_cursor = next_slot;
6389 }
6390
6391 fn detail_dep_list(&self) -> Vec<String> {
6394 let Some(issue) = self.selected_issue() else {
6395 return Vec::new();
6396 };
6397 let mut deps = self.analyzer.graph.blockers(&issue.id);
6398 deps.extend(self.analyzer.graph.dependents(&issue.id));
6399 deps
6400 }
6401
6402 fn move_detail_dep_relative(&mut self, delta: isize) {
6403 if delta == 0 {
6404 return;
6405 }
6406 if self.focus == FocusPane::List {
6407 self.move_selection_relative(delta);
6408 return;
6409 }
6410 let dep_len = self.detail_dep_list().len();
6411 if dep_len == 0 {
6412 self.detail_dep_cursor = 0;
6413 return;
6414 }
6415 let max_slot = dep_len.saturating_sub(1);
6416 let next_slot = if delta >= 0 {
6417 self.detail_dep_cursor
6418 .saturating_add(delta.unsigned_abs())
6419 .min(max_slot)
6420 } else {
6421 self.detail_dep_cursor.saturating_sub(delta.unsigned_abs())
6422 };
6423 self.detail_dep_cursor = next_slot;
6424 }
6425
6426 fn history_timeline_events(&self) -> Vec<HistoryTimelineEvent> {
6427 let mut events = self
6428 .analyzer
6429 .history(None, 0)
6430 .into_iter()
6431 .flat_map(|history| {
6432 history
6433 .events
6434 .into_iter()
6435 .map(move |event| HistoryTimelineEvent {
6436 issue_id: history.id.clone(),
6437 issue_title: history.title.clone(),
6438 issue_status: history.status.clone(),
6439 event_kind: event.kind,
6440 event_timestamp: event.timestamp,
6441 event_details: event.details,
6442 })
6443 })
6444 .collect::<Vec<_>>();
6445
6446 events.sort_by(|left, right| {
6447 cmp_opt_datetime(left.event_timestamp, right.event_timestamp, true)
6448 .then_with(|| left.issue_id.cmp(&right.issue_id))
6449 .then_with(|| left.event_kind.cmp(&right.event_kind))
6450 });
6451
6452 events
6453 }
6454
6455 fn history_timeline_events_filtered(&self) -> Vec<HistoryTimelineEvent> {
6456 let query = self.history_search_query.trim().to_ascii_lowercase();
6457 if query.is_empty() {
6458 return self.history_timeline_events();
6459 }
6460
6461 self.history_timeline_events()
6462 .into_iter()
6463 .filter(|event| {
6464 event.issue_id.to_ascii_lowercase().contains(&query)
6465 || event.issue_title.to_ascii_lowercase().contains(&query)
6466 || event.issue_status.to_ascii_lowercase().contains(&query)
6467 || event.event_kind.to_ascii_lowercase().contains(&query)
6468 || event.event_details.to_ascii_lowercase().contains(&query)
6469 || event
6470 .event_timestamp
6471 .map(|dt| {
6472 dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
6473 .to_ascii_lowercase()
6474 })
6475 .is_some_and(|timestamp| timestamp.contains(&query))
6476 })
6477 .collect()
6478 }
6479
6480 fn selected_history_event(&self) -> Option<HistoryTimelineEvent> {
6481 let events = self.history_timeline_events_filtered();
6482 if events.is_empty() {
6483 return None;
6484 }
6485
6486 let slot = self
6487 .history_event_cursor
6488 .min(events.len().saturating_sub(1));
6489 events.get(slot).cloned()
6490 }
6491
6492 fn selected_history_bead_commit(&self) -> Option<HistoryCommitCompat> {
6493 let issue = self.selected_issue()?;
6494 let commits = self.history_filtered_bead_commits(&issue.id);
6495 if commits.is_empty() {
6496 return None;
6497 }
6498
6499 let slot = self
6500 .history_bead_commit_cursor
6501 .min(commits.len().saturating_sub(1));
6502 commits.get(slot).map(|commit| (*commit).clone())
6503 }
6504
6505 fn issue_matches_filter(&self, issue: &Issue) -> bool {
6506 let base = match self.list_filter {
6507 ListFilter::All => true,
6508 ListFilter::Open => issue.is_open_like(),
6509 ListFilter::InProgress => issue.status.eq_ignore_ascii_case("in_progress"),
6510 ListFilter::Blocked => issue.status.eq_ignore_ascii_case("blocked"),
6511 ListFilter::Closed => issue.is_closed_like(),
6512 ListFilter::Ready => {
6513 issue.is_open_like() && self.analyzer.graph.open_blockers(&issue.id).is_empty()
6514 }
6515 };
6516 if !base {
6517 return false;
6518 }
6519 if let Some(ref label) = self.modal_label_filter {
6520 if !issue
6521 .labels
6522 .iter()
6523 .any(|candidate| candidate.eq_ignore_ascii_case(label))
6524 {
6525 return false;
6526 }
6527 }
6528 if let Some(ref repo) = self.modal_repo_filter {
6529 if issue.source_repo != *repo {
6530 return false;
6531 }
6532 }
6533 true
6534 }
6535
6536 fn visible_issue_indices(&self) -> Vec<usize> {
6537 let mut visible = self
6538 .analyzer
6539 .issues
6540 .iter()
6541 .enumerate()
6542 .filter_map(|(index, issue)| self.issue_matches_filter(issue).then_some(index))
6543 .collect::<Vec<_>>();
6544
6545 {
6546 visible.sort_by(|left_index, right_index| {
6547 let left_issue = &self.analyzer.issues[*left_index];
6548 let right_issue = &self.analyzer.issues[*right_index];
6549
6550 match self.list_sort {
6551 ListSort::Default => {
6552 let l_open = left_issue.is_open_like();
6553 let r_open = right_issue.is_open_like();
6554 r_open
6555 .cmp(&l_open)
6556 .then_with(|| left_issue.priority.cmp(&right_issue.priority))
6557 .then_with(|| left_issue.id.cmp(&right_issue.id))
6558 }
6559 ListSort::CreatedAsc => {
6560 cmp_opt_datetime(left_issue.created_at, right_issue.created_at, false)
6561 .then_with(|| left_issue.id.cmp(&right_issue.id))
6562 }
6563 ListSort::CreatedDesc => {
6564 cmp_opt_datetime(left_issue.created_at, right_issue.created_at, true)
6565 .then_with(|| left_issue.id.cmp(&right_issue.id))
6566 }
6567 ListSort::Priority => left_issue
6568 .priority
6569 .cmp(&right_issue.priority)
6570 .then_with(|| left_issue.id.cmp(&right_issue.id)),
6571 ListSort::Updated => cmp_opt_datetime(
6572 left_issue.updated_at.or(left_issue.created_at),
6573 right_issue.updated_at.or(right_issue.created_at),
6574 true,
6575 )
6576 .then_with(|| left_issue.id.cmp(&right_issue.id)),
6577 ListSort::PageRank => {
6578 let l = self
6579 .analyzer
6580 .metrics
6581 .pagerank
6582 .get(&left_issue.id)
6583 .copied()
6584 .unwrap_or_default();
6585 let r = self
6586 .analyzer
6587 .metrics
6588 .pagerank
6589 .get(&right_issue.id)
6590 .copied()
6591 .unwrap_or_default();
6592 r.total_cmp(&l)
6593 .then_with(|| left_issue.id.cmp(&right_issue.id))
6594 }
6595 ListSort::Blockers => {
6596 let l = self
6597 .analyzer
6598 .metrics
6599 .blocks_count
6600 .get(&left_issue.id)
6601 .copied()
6602 .unwrap_or_default();
6603 let r = self
6604 .analyzer
6605 .metrics
6606 .blocks_count
6607 .get(&right_issue.id)
6608 .copied()
6609 .unwrap_or_default();
6610 r.cmp(&l).then_with(|| left_issue.id.cmp(&right_issue.id))
6611 }
6612 }
6613 });
6614 }
6615
6616 visible
6617 }
6618
6619 fn history_visible_issue_indices(&self) -> Vec<usize> {
6620 let visible = self.visible_issue_indices();
6621 if !matches!(self.mode, ViewMode::History)
6622 || !matches!(self.history_view_mode, HistoryViewMode::Bead)
6623 {
6624 return visible;
6625 }
6626
6627 let query = self.history_search_query.trim().to_ascii_lowercase();
6628 if query.is_empty()
6629 && self.history_file_tree_filter.is_none()
6630 && self.history_min_confidence() == 0.0
6631 {
6632 return visible;
6633 }
6634
6635 let cache = self.history_git_cache.as_ref();
6636 visible
6637 .into_iter()
6638 .filter(|index| {
6639 self.analyzer.issues.get(*index).is_some_and(|issue| {
6640 let filtered_commits = if cache.is_some() {
6641 self.history_filtered_bead_commits(&issue.id)
6642 } else {
6643 Vec::new()
6644 };
6645 if (self.history_file_tree_filter.is_some()
6646 || self.history_min_confidence() > 0.0)
6647 && filtered_commits.is_empty()
6648 {
6649 return false;
6650 }
6651
6652 match self.history_search_mode {
6653 HistorySearchMode::Bead => {
6654 issue.id.to_ascii_lowercase().contains(&query)
6655 || issue.title.to_ascii_lowercase().contains(&query)
6656 }
6657 HistorySearchMode::Sha => filtered_commits.iter().any(|commit| {
6658 commit.sha.to_ascii_lowercase().starts_with(&query)
6659 || commit.short_sha.to_ascii_lowercase().starts_with(&query)
6660 }),
6661 HistorySearchMode::Commit => filtered_commits
6662 .iter()
6663 .any(|commit| commit.message.to_ascii_lowercase().contains(&query)),
6664 HistorySearchMode::Author => cache.is_some_and(|c| {
6665 c.histories.get(&issue.id).is_some_and(|history| {
6666 history.last_author.to_ascii_lowercase().contains(&query)
6667 || filtered_commits.iter().any(|commit| {
6668 commit.author.to_ascii_lowercase().contains(&query)
6669 || commit
6670 .author_email
6671 .to_ascii_lowercase()
6672 .contains(&query)
6673 })
6674 })
6675 }),
6676 HistorySearchMode::All => {
6677 issue.id.to_ascii_lowercase().contains(&query)
6678 || issue.title.to_ascii_lowercase().contains(&query)
6679 || issue.status.to_ascii_lowercase().contains(&query)
6680 || issue.issue_type.to_ascii_lowercase().contains(&query)
6681 || issue
6682 .labels
6683 .iter()
6684 .any(|label| label.to_ascii_lowercase().contains(&query))
6685 }
6686 }
6687 })
6688 })
6689 .collect()
6690 }
6691
6692 fn visible_issue_indices_for_list_nav(&self) -> Vec<usize> {
6693 if matches!(self.mode, ViewMode::History)
6694 && matches!(self.history_view_mode, HistoryViewMode::Bead)
6695 {
6696 return self.history_visible_issue_indices();
6697 }
6698 if matches!(self.mode, ViewMode::Graph) {
6699 return self.graph_visible_issue_indices();
6700 }
6701 if matches!(self.mode, ViewMode::Insights) {
6702 return self.insights_visible_issue_indices_for_list_nav();
6703 }
6704
6705 self.visible_issue_indices()
6706 }
6707
6708 fn selected_visible_slot(&self, visible: &[usize]) -> Option<usize> {
6709 visible.iter().position(|index| *index == self.selected)
6710 }
6711
6712 fn preserve_off_queue_ranked_context(&self) -> bool {
6713 match self.mode {
6714 ViewMode::Insights => {
6715 self.insights_heatmap.is_none()
6716 && self.insights_search_query.trim().is_empty()
6717 && !self
6718 .insights_visible_issue_indices_for_list_nav()
6719 .contains(&self.selected)
6720 }
6721 ViewMode::Graph => {
6722 self.graph_search_query.trim().is_empty()
6723 && !self.graph_visible_issue_indices().contains(&self.selected)
6724 }
6725 _ => false,
6726 }
6727 }
6728
6729 fn ensure_selected_visible(&mut self) {
6730 let visible = self.visible_issue_indices_for_list_nav();
6731 if visible.is_empty() {
6732 self.set_selected_index(0);
6733 return;
6734 }
6735 if !visible.contains(&self.selected) {
6736 self.set_selected_index(visible[0]);
6737 }
6738 }
6739
6740 fn sync_ranked_list_context(&mut self) {
6741 self.ensure_selected_visible();
6742 self.sync_insights_heatmap_selection();
6743 }
6744
6745 fn reselect_insights_panel_context(&mut self) {
6746 if !self.insights_search_query.trim().is_empty() {
6747 self.select_current_insights_search_match();
6748 self.sync_insights_heatmap_selection();
6749 return;
6750 }
6751
6752 let visible = self.insights_visible_issue_indices_for_list_nav();
6753 if visible.contains(&self.selected) {
6754 self.select_first_visible();
6755 } else {
6756 self.sync_insights_heatmap_selection();
6757 }
6758 }
6759
6760 fn reselect_ranked_list_context(&mut self) {
6761 match self.mode {
6762 ViewMode::Graph if !self.graph_search_query.trim().is_empty() => {
6763 self.select_current_graph_search_match();
6764 }
6765 ViewMode::Insights if !self.insights_search_query.trim().is_empty() => {
6766 self.select_current_insights_search_match();
6767 }
6768 _ => self.select_first_visible(),
6769 }
6770 self.sync_insights_heatmap_selection();
6771 }
6772
6773 fn move_selection_relative(&mut self, delta: isize) {
6774 let visible = self.visible_issue_indices_for_list_nav();
6775 if visible.is_empty() {
6776 return;
6777 }
6778
6779 let current_slot = self.selected_visible_slot(&visible).unwrap_or(0);
6780 let max_slot = visible.len().saturating_sub(1);
6781 let next_slot = if delta >= 0 {
6782 current_slot
6783 .saturating_add(delta.unsigned_abs())
6784 .min(max_slot)
6785 } else {
6786 current_slot.saturating_sub(delta.unsigned_abs())
6787 };
6788 self.set_selected_index(visible[next_slot]);
6789 }
6790
6791 fn list_page_step(&self) -> usize {
6792 let body_rows = usize::from(cached_view_height().saturating_sub(3));
6793 if matches!(self.mode, ViewMode::Main) {
6794 body_rows
6795 .saturating_sub(self.main_search_banner_lines().len())
6796 .max(5)
6797 } else {
6798 body_rows.saturating_sub(2).max(5)
6799 }
6800 }
6801
6802 fn select_first_visible(&mut self) {
6803 if let Some(index) = self.visible_issue_indices_for_list_nav().first().copied() {
6804 self.set_selected_index(index);
6805 self.list_scroll_offset.set(0);
6806 }
6807 }
6808
6809 fn select_last_visible(&mut self) {
6810 if let Some(index) = self.visible_issue_indices_for_list_nav().last().copied() {
6811 self.set_selected_index(index);
6812 }
6813 }
6814
6815 fn has_active_filter(&self) -> bool {
6816 self.list_filter != ListFilter::All
6817 || self.modal_label_filter.is_some()
6818 || self.modal_repo_filter.is_some()
6819 }
6820
6821 fn should_clear_filter_with_all_shortcut(&self) -> bool {
6822 self.has_active_filter() && !matches!(self.mode, ViewMode::Actionable)
6823 }
6824
6825 fn set_list_filter(&mut self, list_filter: ListFilter) {
6826 self.list_filter = list_filter;
6827 if matches!(list_filter, ListFilter::All) {
6828 self.modal_label_filter = None;
6829 self.modal_repo_filter = None;
6830 }
6831 self.list_scroll_offset.set(0);
6832 self.ensure_selected_visible();
6833 self.sync_insights_heatmap_selection();
6834 self.focus = FocusPane::List;
6835 self.rebuild_tree_if_active();
6836 }
6837
6838 fn rebuild_tree_if_active(&mut self) {
6841 if matches!(self.mode, ViewMode::Tree) {
6842 self.build_tree_flat_nodes();
6843 if self.tree_cursor >= self.tree_flat_nodes.len() {
6844 self.tree_cursor = self.tree_flat_nodes.len().saturating_sub(1);
6845 }
6846 }
6847 }
6848
6849 fn cycle_list_sort(&mut self) {
6850 self.list_sort = self.list_sort.next();
6851 self.ensure_selected_visible();
6852 self.sync_insights_heatmap_selection();
6853 self.focus = FocusPane::List;
6854 }
6855
6856 fn cycle_board_grouping(&mut self) {
6857 self.board_grouping = self.board_grouping.next();
6858 self.ensure_selected_visible();
6859 self.focus = FocusPane::List;
6860 }
6861
6862 fn toggle_board_empty_visibility(&mut self) {
6863 self.board_empty_visibility = self.board_empty_visibility.next();
6864 self.ensure_selected_visible();
6865 self.focus = FocusPane::List;
6866 }
6867
6868 fn scroll_board_detail(&mut self, delta: isize) {
6869 if delta == 0 || !matches!(self.mode, ViewMode::Board) || self.focus != FocusPane::Detail {
6870 return;
6871 }
6872
6873 if delta > 0 {
6874 self.board_detail_scroll_offset = self
6875 .board_detail_scroll_offset
6876 .saturating_add(delta.unsigned_abs());
6877 } else {
6878 self.board_detail_scroll_offset = self
6879 .board_detail_scroll_offset
6880 .saturating_sub(delta.unsigned_abs());
6881 }
6882 }
6883
6884 fn scroll_detail(&mut self, delta: isize) {
6886 if delta == 0 || self.focus != FocusPane::Detail {
6887 return;
6888 }
6889
6890 if delta > 0 {
6891 self.detail_scroll_offset = self
6892 .detail_scroll_offset
6893 .saturating_add(delta.unsigned_abs());
6894 } else {
6895 self.detail_scroll_offset = self
6896 .detail_scroll_offset
6897 .saturating_sub(delta.unsigned_abs());
6898 }
6899 }
6900
6901 fn set_selected_index(&mut self, index: usize) {
6902 let changed = self.selected != index;
6903 self.selected = index;
6904 if changed {
6905 self.detail_dep_cursor = 0;
6906 self.detail_scroll_offset = 0;
6907 if matches!(self.mode, ViewMode::Board) {
6908 self.board_detail_scroll_offset = 0;
6909 }
6910 }
6911 }
6912
6913 fn toggle_insights_explanations(&mut self) {
6914 self.insights_show_explanations = !self.insights_show_explanations;
6915 self.focus = FocusPane::List;
6916 }
6917
6918 fn toggle_insights_calc_proof(&mut self) {
6919 self.insights_show_calc_proof = !self.insights_show_calc_proof;
6920 self.focus = FocusPane::List;
6921 }
6922
6923 fn board_lane_indices(&self) -> Vec<(String, Vec<usize>)> {
6924 let visible = self.visible_issue_indices();
6925
6926 let mut lanes = match self.board_grouping {
6927 BoardGrouping::Status => {
6928 let mut open = Vec::<usize>::new();
6929 let mut in_progress = Vec::<usize>::new();
6930 let mut blocked = Vec::<usize>::new();
6931 let mut closed = Vec::<usize>::new();
6932 let mut other = Vec::<usize>::new();
6933
6934 for index in visible {
6935 let issue = &self.analyzer.issues[index];
6936 if issue.is_closed_like() {
6937 closed.push(index);
6938 } else if issue.status.eq_ignore_ascii_case("blocked") {
6939 blocked.push(index);
6940 } else if issue.status.eq_ignore_ascii_case("in_progress") {
6941 in_progress.push(index);
6942 } else if issue.status.eq_ignore_ascii_case("open") {
6943 open.push(index);
6944 } else {
6945 other.push(index);
6946 }
6947 }
6948
6949 vec![
6950 ("open".to_string(), open),
6951 ("in_progress".to_string(), in_progress),
6952 ("blocked".to_string(), blocked),
6953 ("closed".to_string(), closed),
6954 ("other".to_string(), other),
6955 ]
6956 }
6957 BoardGrouping::Priority => {
6958 let mut p0 = Vec::<usize>::new();
6959 let mut p1 = Vec::<usize>::new();
6960 let mut p2 = Vec::<usize>::new();
6961 let mut p3_plus = Vec::<usize>::new();
6962
6963 for index in visible {
6964 let issue = &self.analyzer.issues[index];
6965 match issue.priority {
6966 0 => p0.push(index),
6967 1 => p1.push(index),
6968 2 => p2.push(index),
6969 _ => p3_plus.push(index),
6970 }
6971 }
6972
6973 vec![
6974 ("p0".to_string(), p0),
6975 ("p1".to_string(), p1),
6976 ("p2".to_string(), p2),
6977 ("p3+".to_string(), p3_plus),
6978 ]
6979 }
6980 BoardGrouping::Type => {
6981 let mut by_type = std::collections::BTreeMap::<String, Vec<usize>>::new();
6982 for index in visible {
6983 let issue = &self.analyzer.issues[index];
6984 let key = if issue.issue_type.trim().is_empty() {
6985 "unknown".to_string()
6986 } else {
6987 issue.issue_type.to_lowercase()
6988 };
6989 by_type.entry(key).or_default().push(index);
6990 }
6991 by_type.into_iter().collect()
6992 }
6993 };
6994
6995 if !self
6996 .board_empty_visibility
6997 .should_show_empty(self.board_grouping)
6998 {
6999 lanes.retain(|(_, indices)| !indices.is_empty());
7000 }
7001
7002 lanes
7003 }
7004
7005 fn select_first_in_board_lane(&mut self, lane_position: usize) {
7006 if !matches!(self.mode, ViewMode::Board) || lane_position == 0 {
7007 return;
7008 }
7009
7010 if let Some((_, indices)) = self.board_lane_indices().get(lane_position - 1)
7011 && let Some(index) = indices.first().copied()
7012 {
7013 self.set_selected_index(index);
7014 }
7015 }
7016
7017 fn current_board_lane_slot(&self) -> Option<usize> {
7018 let lanes = self.board_lane_indices();
7019 lanes
7020 .iter()
7021 .position(|(_, indices)| indices.contains(&self.selected))
7022 .or_else(|| lanes.iter().position(|(_, indices)| !indices.is_empty()))
7023 }
7024
7025 fn select_first_in_non_empty_board_lane(&mut self) {
7026 if !matches!(self.mode, ViewMode::Board) {
7027 return;
7028 }
7029
7030 if let Some((_, indices)) = self
7031 .board_lane_indices()
7032 .into_iter()
7033 .find(|(_, indices)| !indices.is_empty())
7034 && let Some(index) = indices.first().copied()
7035 {
7036 self.set_selected_index(index);
7037 }
7038 }
7039
7040 fn select_last_in_non_empty_board_lane(&mut self) {
7041 if !matches!(self.mode, ViewMode::Board) {
7042 return;
7043 }
7044
7045 if let Some((_, indices)) = self
7046 .board_lane_indices()
7047 .into_iter()
7048 .rev()
7049 .find(|(_, indices)| !indices.is_empty())
7050 && let Some(index) = indices.last().copied()
7051 {
7052 self.set_selected_index(index);
7053 }
7054 }
7055
7056 fn move_board_lane_relative(&mut self, delta: isize) {
7057 if !matches!(self.mode, ViewMode::Board)
7058 || !matches!(self.focus, FocusPane::List | FocusPane::Detail)
7059 || delta == 0
7060 {
7061 return;
7062 }
7063
7064 let lanes = self.board_lane_indices();
7065 if lanes.is_empty() {
7066 return;
7067 }
7068
7069 let Some(current_lane_slot) = self.current_board_lane_slot() else {
7070 return;
7071 };
7072
7073 let current_row = lanes
7074 .get(current_lane_slot)
7075 .and_then(|(_, indices)| indices.iter().position(|index| *index == self.selected))
7076 .unwrap_or(0);
7077
7078 let lane_count = isize::try_from(lanes.len()).unwrap_or(0);
7079 let mut target_lane_slot = isize::try_from(current_lane_slot).unwrap_or(0) + delta.signum();
7080
7081 while target_lane_slot >= 0 && target_lane_slot < lane_count {
7082 let slot = usize::try_from(target_lane_slot).unwrap_or(0);
7083 if let Some((_, indices)) = lanes.get(slot)
7084 && !indices.is_empty()
7085 {
7086 let target_row = current_row.min(indices.len().saturating_sub(1));
7087 self.set_selected_index(indices[target_row]);
7088 return;
7089 }
7090 target_lane_slot += delta.signum();
7091 }
7092 }
7093
7094 fn move_board_row_relative(&mut self, delta: isize) {
7095 if !matches!(self.mode, ViewMode::Board)
7096 || !matches!(self.focus, FocusPane::List | FocusPane::Detail)
7097 || delta == 0
7098 {
7099 return;
7100 }
7101
7102 if self.focus == FocusPane::Detail && !self.detail_dep_list().is_empty() {
7103 self.move_detail_dep_relative(delta);
7104 return;
7105 }
7106
7107 let lanes = self.board_lane_indices();
7108 let Some(lane_slot) = self.current_board_lane_slot() else {
7109 return;
7110 };
7111 let Some((_, indices)) = lanes.get(lane_slot) else {
7112 return;
7113 };
7114 if indices.is_empty() {
7115 return;
7116 }
7117
7118 let current_row = indices
7119 .iter()
7120 .position(|index| *index == self.selected)
7121 .unwrap_or(0);
7122 let max_row = indices.len().saturating_sub(1);
7123 let next_row = if delta >= 0 {
7124 current_row
7125 .saturating_add(delta.unsigned_abs())
7126 .min(max_row)
7127 } else {
7128 current_row.saturating_sub(delta.unsigned_abs())
7129 };
7130
7131 self.set_selected_index(indices[next_row]);
7132 }
7133
7134 fn start_board_search(&mut self) {
7135 if !matches!(self.mode, ViewMode::Board)
7136 || !matches!(self.focus, FocusPane::List | FocusPane::Detail)
7137 {
7138 return;
7139 }
7140
7141 self.board_search_active = true;
7142 self.board_search_query.clear();
7143 self.board_search_match_cursor = 0;
7144 }
7145
7146 fn finish_board_search(&mut self) {
7147 self.board_search_active = false;
7148 }
7149
7150 fn cancel_board_search(&mut self) {
7151 self.board_search_active = false;
7152 self.board_search_query.clear();
7153 self.board_search_match_cursor = 0;
7154 }
7155
7156 fn board_search_matches(&self) -> Vec<usize> {
7157 let query = self.board_search_query.trim().to_ascii_lowercase();
7158 if query.is_empty() {
7159 return Vec::new();
7160 }
7161
7162 self.board_visible_issue_indices_in_display_order()
7163 .into_iter()
7164 .filter(|index| {
7165 self.analyzer.issues.get(*index).is_some_and(|issue| {
7166 issue.id.to_ascii_lowercase().contains(&query)
7167 || issue.title.to_ascii_lowercase().contains(&query)
7168 || issue.status.to_ascii_lowercase().contains(&query)
7169 || issue.issue_type.to_ascii_lowercase().contains(&query)
7170 || issue
7171 .labels
7172 .iter()
7173 .any(|label| label.to_ascii_lowercase().contains(&query))
7174 })
7175 })
7176 .collect()
7177 }
7178
7179 fn select_current_board_search_match(&mut self) {
7180 let matches = self.board_search_matches();
7181 if matches.is_empty() {
7182 return;
7183 }
7184
7185 self.board_search_match_cursor = self
7186 .board_search_match_cursor
7187 .min(matches.len().saturating_sub(1));
7188 self.set_selected_index(matches[self.board_search_match_cursor]);
7189 }
7190
7191 fn move_board_search_match_relative(&mut self, delta: isize) {
7192 let matches = self.board_search_matches();
7193 if matches.is_empty() || delta == 0 {
7194 return;
7195 }
7196
7197 let len = matches.len();
7198 let current = self.board_search_match_cursor.min(len.saturating_sub(1));
7199 let step = delta.unsigned_abs() % len;
7200 let next = if delta > 0 {
7201 (current + step) % len
7202 } else {
7203 (current + len - step) % len
7204 };
7205
7206 self.board_search_match_cursor = next;
7207 self.set_selected_index(matches[next]);
7208 }
7209
7210 fn start_graph_search(&mut self) {
7213 if !matches!(self.mode, ViewMode::Graph)
7214 || !matches!(self.focus, FocusPane::List | FocusPane::Detail)
7215 {
7216 return;
7217 }
7218
7219 self.focus = FocusPane::List;
7220 self.graph_search_active = true;
7221 self.graph_search_query.clear();
7222 self.graph_search_match_cursor = 0;
7223 }
7224
7225 fn finish_graph_search(&mut self) {
7226 self.graph_search_active = false;
7227 }
7228
7229 fn cancel_graph_search(&mut self) {
7230 self.graph_search_active = false;
7231 self.graph_search_query.clear();
7232 self.graph_search_match_cursor = 0;
7233 }
7234
7235 fn graph_search_matches(&self) -> Vec<usize> {
7236 let query = self.graph_search_query.trim().to_ascii_lowercase();
7237 if query.is_empty() {
7238 return Vec::new();
7239 }
7240 self.graph_visible_issue_indices()
7241 .into_iter()
7242 .filter(|&index| {
7243 let issue = &self.analyzer.issues[index];
7244 issue.id.to_ascii_lowercase().contains(&query)
7245 || issue.title.to_ascii_lowercase().contains(&query)
7246 })
7247 .collect()
7248 }
7249
7250 fn select_current_graph_search_match(&mut self) {
7251 let matches = self.graph_search_matches();
7252 if matches.is_empty() {
7253 return;
7254 }
7255
7256 if let Some(current) = matches.iter().position(|&index| index == self.selected) {
7257 self.graph_search_match_cursor = current;
7258 self.set_selected_index(matches[current]);
7259 return;
7260 }
7261
7262 self.graph_search_match_cursor = self
7263 .graph_search_match_cursor
7264 .min(matches.len().saturating_sub(1));
7265 self.set_selected_index(matches[self.graph_search_match_cursor]);
7266 }
7267
7268 fn move_graph_search_match_relative(&mut self, delta: isize) {
7269 let matches = self.graph_search_matches();
7270 if matches.is_empty() || delta == 0 {
7271 return;
7272 }
7273
7274 let len = matches.len();
7275 let current = self.graph_search_match_cursor.min(len.saturating_sub(1));
7276 let step = delta.unsigned_abs() % len;
7277 let next = if delta > 0 {
7278 (current + step) % len
7279 } else {
7280 (current + len - step) % len
7281 };
7282
7283 self.graph_search_match_cursor = next;
7284 self.set_selected_index(matches[next]);
7285 }
7286
7287 fn issue_index_for_id(&self, issue_id: &str) -> Option<usize> {
7288 self.analyzer
7289 .issues
7290 .iter()
7291 .position(|issue| issue.id == issue_id)
7292 }
7293
7294 fn insights_visible_issue_indices_for_list_nav(&self) -> Vec<usize> {
7295 if let Some(state) = self.insights_heatmap.as_ref() {
7296 let data = self.insights_heatmap_data();
7297 let row = state
7298 .row
7299 .min(INSIGHTS_HEATMAP_DEPTH_LABELS.len().saturating_sub(1));
7300 let col = state
7301 .col
7302 .min(INSIGHTS_HEATMAP_SCORE_LABELS.len().saturating_sub(1));
7303 return data.issue_ids[row][col]
7304 .iter()
7305 .filter_map(|issue_id| self.issue_index_for_id(issue_id))
7306 .collect();
7307 }
7308
7309 let insights = self.analyzer.insights();
7310 let ids = match self.insights_panel {
7311 InsightsPanel::Bottlenecks => insights
7312 .bottlenecks
7313 .iter()
7314 .map(|item| item.id.clone())
7315 .collect::<Vec<_>>(),
7316 InsightsPanel::Keystones => {
7317 let mut keystones = self
7318 .analyzer
7319 .issues
7320 .iter()
7321 .filter(|issue| issue.is_open_like())
7322 .filter_map(|issue| {
7323 self.analyzer
7324 .metrics
7325 .critical_depth
7326 .get(&issue.id)
7327 .copied()
7328 .map(|depth| (issue.id.as_str(), depth))
7329 })
7330 .filter(|(_, depth)| *depth > 0)
7331 .collect::<Vec<_>>();
7332 keystones
7333 .sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(right.0)));
7334 keystones
7335 .into_iter()
7336 .map(|(id, _)| id.to_string())
7337 .collect::<Vec<_>>()
7338 }
7339 InsightsPanel::CriticalPath => insights.critical_path.clone(),
7340 InsightsPanel::Influencers => insights
7341 .influencers
7342 .iter()
7343 .map(|item| item.id.clone())
7344 .collect::<Vec<_>>(),
7345 InsightsPanel::Betweenness => insights
7346 .betweenness
7347 .iter()
7348 .map(|item| item.id.clone())
7349 .collect::<Vec<_>>(),
7350 InsightsPanel::Hubs => insights
7351 .hubs
7352 .iter()
7353 .map(|item| item.id.clone())
7354 .collect::<Vec<_>>(),
7355 InsightsPanel::Authorities => insights
7356 .authorities
7357 .iter()
7358 .map(|item| item.id.clone())
7359 .collect::<Vec<_>>(),
7360 InsightsPanel::Cores => insights
7361 .cores
7362 .iter()
7363 .map(|item| item.id.clone())
7364 .collect::<Vec<_>>(),
7365 InsightsPanel::CutPoints => insights.articulation_points.clone(),
7366 InsightsPanel::Slack => insights.slack.clone(),
7367 InsightsPanel::Priority => self
7368 .analyzer
7369 .priority(0.0, 15, None, None)
7370 .into_iter()
7371 .map(|item| item.id)
7372 .collect::<Vec<_>>(),
7373 InsightsPanel::Cycles => Vec::new(),
7374 };
7375
7376 let indices = ids
7377 .iter()
7378 .filter_map(|issue_id| self.issue_index_for_id(issue_id))
7379 .collect::<Vec<_>>();
7380 if indices.is_empty() {
7381 self.visible_issue_indices()
7382 } else {
7383 indices
7384 }
7385 }
7386
7387 fn start_insights_search(&mut self) {
7390 if !matches!(self.mode, ViewMode::Insights)
7391 || !matches!(self.focus, FocusPane::List | FocusPane::Detail)
7392 {
7393 return;
7394 }
7395
7396 self.focus = FocusPane::List;
7397 self.insights_search_active = true;
7398 self.insights_search_query.clear();
7399 self.insights_search_match_cursor = 0;
7400 }
7401
7402 fn finish_insights_search(&mut self) {
7403 self.insights_search_active = false;
7404 }
7405
7406 fn cancel_insights_search(&mut self) {
7407 self.insights_search_active = false;
7408 self.insights_search_query.clear();
7409 self.insights_search_match_cursor = 0;
7410 }
7411
7412 fn insights_search_matches(&self) -> Vec<usize> {
7413 let query = self.insights_search_query.trim().to_ascii_lowercase();
7414 if query.is_empty() {
7415 return Vec::new();
7416 }
7417 self.insights_visible_issue_indices_for_list_nav()
7418 .into_iter()
7419 .filter(|&index| {
7420 let issue = &self.analyzer.issues[index];
7421 issue.id.to_ascii_lowercase().contains(&query)
7422 || issue.title.to_ascii_lowercase().contains(&query)
7423 })
7424 .collect()
7425 }
7426
7427 fn select_current_insights_search_match(&mut self) {
7428 let matches = self.insights_search_matches();
7429 if matches.is_empty() {
7430 return;
7431 }
7432
7433 if let Some(current) = matches.iter().position(|&index| index == self.selected) {
7434 self.insights_search_match_cursor = current;
7435 self.set_selected_index(matches[current]);
7436 return;
7437 }
7438
7439 self.insights_search_match_cursor = self
7440 .insights_search_match_cursor
7441 .min(matches.len().saturating_sub(1));
7442 self.set_selected_index(matches[self.insights_search_match_cursor]);
7443 }
7444
7445 fn move_insights_search_match_relative(&mut self, delta: isize) {
7446 let matches = self.insights_search_matches();
7447 if matches.is_empty() || delta == 0 {
7448 return;
7449 }
7450
7451 let len = matches.len();
7452 let current = self.insights_search_match_cursor.min(len.saturating_sub(1));
7453 let step = delta.unsigned_abs() % len;
7454 let next = if delta > 0 {
7455 (current + step) % len
7456 } else {
7457 (current + len - step) % len
7458 };
7459
7460 self.insights_search_match_cursor = next;
7461 self.set_selected_index(matches[next]);
7462 }
7463
7464 fn start_main_search(&mut self) {
7467 if !matches!(self.mode, ViewMode::Main) || self.focus != FocusPane::List {
7468 return;
7469 }
7470
7471 self.main_search_active = true;
7472 self.main_search_query.clear();
7473 self.main_search_match_cursor = 0;
7474 }
7475
7476 fn finish_main_search(&mut self) {
7477 self.main_search_active = false;
7478 }
7479
7480 fn cancel_main_search(&mut self) {
7481 self.main_search_active = false;
7482 self.main_search_query.clear();
7483 self.main_search_match_cursor = 0;
7484 }
7485
7486 fn main_search_matches(&self) -> Vec<usize> {
7487 let query = self.main_search_query.trim().to_ascii_lowercase();
7488 if query.is_empty() {
7489 return Vec::new();
7490 }
7491 self.visible_issue_indices()
7492 .into_iter()
7493 .filter(|&index| {
7494 let issue = &self.analyzer.issues[index];
7495 issue.id.to_ascii_lowercase().contains(&query)
7496 || issue.title.to_ascii_lowercase().contains(&query)
7497 || issue.status.to_ascii_lowercase().contains(&query)
7498 || issue.issue_type.to_ascii_lowercase().contains(&query)
7499 || issue.description.to_ascii_lowercase().contains(&query)
7500 || issue.notes.to_ascii_lowercase().contains(&query)
7501 || issue.design.to_ascii_lowercase().contains(&query)
7502 || issue
7503 .acceptance_criteria
7504 .to_ascii_lowercase()
7505 .contains(&query)
7506 || issue.assignee.to_ascii_lowercase().contains(&query)
7507 || issue
7508 .labels
7509 .iter()
7510 .any(|label| label.to_ascii_lowercase().contains(&query))
7511 })
7512 .collect()
7513 }
7514
7515 fn select_current_main_search_match(&mut self) {
7516 let matches = self.main_search_matches();
7517 if matches.is_empty() {
7518 return;
7519 }
7520
7521 self.main_search_match_cursor = self
7522 .main_search_match_cursor
7523 .min(matches.len().saturating_sub(1));
7524 self.set_selected_index(matches[self.main_search_match_cursor]);
7525 }
7526
7527 fn move_main_search_match_relative(&mut self, delta: isize) {
7528 let matches = self.main_search_matches();
7529 if matches.is_empty() || delta == 0 {
7530 return;
7531 }
7532
7533 let len = matches.len();
7534 let current = self.main_search_match_cursor.min(len.saturating_sub(1));
7535 let step = delta.unsigned_abs() % len;
7536 let next = if delta > 0 {
7537 (current + step) % len
7538 } else {
7539 (current + len - step) % len
7540 };
7541
7542 self.main_search_match_cursor = next;
7543 self.set_selected_index(matches[next]);
7544 }
7545
7546 fn select_edge_in_current_board_lane(&mut self, select_last: bool) {
7547 if !matches!(self.mode, ViewMode::Board) {
7548 return;
7549 }
7550
7551 let lanes = self.board_lane_indices();
7552 let Some(lane_slot) = self.current_board_lane_slot() else {
7553 return;
7554 };
7555
7556 let Some((_, indices)) = lanes.get(lane_slot) else {
7557 return;
7558 };
7559
7560 let candidate = if select_last {
7561 indices.last().copied()
7562 } else {
7563 indices.first().copied()
7564 };
7565
7566 if let Some(index) = candidate {
7567 self.set_selected_index(index);
7568 }
7569 }
7570
7571 fn board_visible_issue_indices_in_display_order(&self) -> Vec<usize> {
7572 self.board_lane_indices()
7573 .into_iter()
7574 .flat_map(|(_, indices)| indices)
7575 .collect()
7576 }
7577
7578 fn issue_diff_tag(&self, issue_id: &str) -> Option<DiffTag> {
7579 let diff = self.time_travel_diff.as_ref()?;
7580 if diff
7581 .new_issues
7582 .as_ref()
7583 .is_some_and(|v| v.iter().any(|d| d.id == issue_id))
7584 {
7585 return Some(DiffTag::New);
7586 }
7587 if diff
7588 .reopened_issues
7589 .as_ref()
7590 .is_some_and(|v| v.iter().any(|d| d.id == issue_id))
7591 {
7592 return Some(DiffTag::Reopened);
7593 }
7594 if diff
7595 .modified_issues
7596 .as_ref()
7597 .is_some_and(|v| v.iter().any(|m| m.issue_id == issue_id))
7598 {
7599 return Some(DiffTag::Modified);
7600 }
7601 if diff
7602 .closed_issues
7603 .as_ref()
7604 .is_some_and(|v| v.iter().any(|d| d.id == issue_id))
7605 {
7606 return Some(DiffTag::Closed);
7607 }
7608 None
7609 }
7610
7611 fn selected_issue(&self) -> Option<&Issue> {
7612 let visible = self.visible_issue_indices_for_list_nav();
7613 if visible.is_empty() {
7614 return None;
7615 }
7616 let index = self
7617 .selected_visible_slot(&visible)
7618 .map_or(visible[0], |_| self.selected);
7619 self.analyzer.issues.get(index)
7620 }
7621
7622 fn selected_issue_external_ref_url(&self) -> Option<&str> {
7623 self.selected_issue()
7624 .and_then(|issue| issue.external_ref.as_deref())
7625 .filter(|url| is_http_url(url))
7626 }
7627
7628 fn main_footer_command_hints(&self) -> Vec<CommandHint<'static>> {
7629 let mut hints = vec![
7630 CommandHint {
7631 key: "b/i/g/h",
7632 desc: "modes",
7633 },
7634 CommandHint {
7635 key: "/",
7636 desc: "search",
7637 },
7638 CommandHint {
7639 key: "s",
7640 desc: self.list_sort.label(),
7641 },
7642 CommandHint {
7643 key: "p",
7644 desc: "hints",
7645 },
7646 CommandHint {
7647 key: "C",
7648 desc: "copy",
7649 },
7650 ];
7651 if matches!(self.focus, FocusPane::Detail) {
7652 if self.selected_issue_external_ref_url().is_some() {
7653 hints.push(CommandHint {
7654 key: "o",
7655 desc: "open link",
7656 });
7657 hints.push(CommandHint {
7658 key: "y",
7659 desc: "copy link",
7660 });
7661 }
7662 hints.push(CommandHint {
7663 key: "^j/k",
7664 desc: "scroll",
7665 });
7666 }
7667 hints.extend([
7668 CommandHint {
7669 key: "x",
7670 desc: "export",
7671 },
7672 CommandHint {
7673 key: "O",
7674 desc: "edit",
7675 },
7676 CommandHint {
7677 key: "^←/→",
7678 desc: "resize",
7679 },
7680 CommandHint {
7681 key: "^0",
7682 desc: "reset split",
7683 },
7684 ]);
7685 hints
7686 }
7687
7688 fn graph_footer_command_hints(&self) -> Vec<CommandHint<'static>> {
7689 let mut hints = match self.focus {
7690 FocusPane::List => vec![
7691 CommandHint {
7692 key: "h/l",
7693 desc: "nodes",
7694 },
7695 CommandHint {
7696 key: "j/k",
7697 desc: "nodes",
7698 },
7699 CommandHint {
7700 key: "H/L",
7701 desc: "jump",
7702 },
7703 CommandHint {
7704 key: "Tab",
7705 desc: "detail",
7706 },
7707 CommandHint {
7708 key: "/",
7709 desc: "search",
7710 },
7711 CommandHint {
7712 key: "Enter",
7713 desc: "open details",
7714 },
7715 ],
7716 FocusPane::Detail | FocusPane::Middle => {
7717 let mut hints = vec![
7718 CommandHint {
7719 key: "h/Tab",
7720 desc: "list",
7721 },
7722 CommandHint {
7723 key: "Enter",
7724 desc: "open details",
7725 },
7726 ];
7727 if !self.detail_dep_list().is_empty() {
7728 hints.push(CommandHint {
7729 key: "j/k",
7730 desc: "deps",
7731 });
7732 }
7733 hints.push(CommandHint {
7734 key: "^j/k",
7735 desc: "scroll",
7736 });
7737 hints
7738 }
7739 };
7740 if self.selected_issue_external_ref_url().is_some()
7741 && matches!(self.focus, FocusPane::Detail)
7742 {
7743 hints.push(CommandHint {
7744 key: "o",
7745 desc: "open link",
7746 });
7747 hints.push(CommandHint {
7748 key: "y",
7749 desc: "copy link",
7750 });
7751 }
7752 hints.push(CommandHint {
7753 key: "g/Esc",
7754 desc: "back",
7755 });
7756 hints.push(CommandHint {
7757 key: "^←/→",
7758 desc: "resize",
7759 });
7760 hints.push(CommandHint {
7761 key: "^0",
7762 desc: "reset split",
7763 });
7764 hints
7765 }
7766
7767 fn should_open_selected_issue_external_ref(&self) -> bool {
7768 matches!(
7769 self.mode,
7770 ViewMode::Main | ViewMode::Board | ViewMode::Insights | ViewMode::Graph
7771 ) && matches!(self.focus, FocusPane::Detail)
7772 && self.selected_issue_external_ref_url().is_some()
7773 }
7774
7775 fn should_copy_selected_issue_external_ref(&self) -> bool {
7776 matches!(
7777 self.mode,
7778 ViewMode::Main | ViewMode::Board | ViewMode::Insights | ViewMode::Graph
7779 ) && matches!(self.focus, FocusPane::Detail)
7780 && self.selected_issue_external_ref_url().is_some()
7781 }
7782
7783 fn open_selected_issue_external_ref(&mut self) {
7784 let Some(url) = self.selected_issue_external_ref_url().map(str::to_string) else {
7785 self.status_msg = "No external issue reference".into();
7786 return;
7787 };
7788
7789 if open_url_in_browser(&url) {
7790 self.status_msg = "Opened external issue reference".into();
7791 } else {
7792 self.status_msg = "Could not open browser".into();
7793 }
7794 }
7795
7796 fn copy_selected_issue_external_ref(&mut self) {
7797 let Some(url) = self.selected_issue_external_ref_url().map(str::to_string) else {
7798 self.status_msg = "No external issue reference".into();
7799 return;
7800 };
7801
7802 if copy_text_to_clipboard(&url) {
7803 self.status_msg = "Copied external issue reference to clipboard".into();
7804 } else {
7805 self.status_msg = "Clipboard not available".into();
7806 }
7807 }
7808
7809 fn current_detail_link_row_area(&self) -> Option<Rect> {
7810 let area = cached_detail_content_area();
7811 if area.width == 0 || area.height == 0 {
7812 return None;
7813 }
7814
7815 let (detail_text, line_index, scroll_offset) = match self.mode {
7816 ViewMode::Main => {
7817 let detail_text = self.issue_detail_render_text();
7818 let line_index = detail_text.lines().iter().position(|line| {
7819 ftui::text::Line::spans(line)
7820 .iter()
7821 .any(|span| span.link.is_some())
7822 })?;
7823 (
7824 detail_text,
7825 line_index,
7826 usize::from(saturating_scroll_offset(self.detail_scroll_offset)),
7827 )
7828 }
7829 ViewMode::Board => {
7830 let detail_text = self.board_detail_render_text();
7831 let line_index = detail_text.lines().iter().position(|line| {
7832 ftui::text::Line::spans(line)
7833 .iter()
7834 .any(|span| span.link.is_some())
7835 })?;
7836 (detail_text, line_index, self.board_detail_scroll_offset)
7837 }
7838 ViewMode::Insights => {
7839 let detail_text = self.insights_detail_render_text();
7840 let line_index = detail_text.lines().iter().position(|line| {
7841 ftui::text::Line::spans(line)
7842 .iter()
7843 .any(|span| span.link.is_some())
7844 })?;
7845 (
7846 detail_text,
7847 line_index,
7848 usize::from(saturating_scroll_offset(self.detail_scroll_offset)),
7849 )
7850 }
7851 ViewMode::Graph => {
7852 let detail_text = self.graph_detail_render_text();
7853 let line_index = detail_text.lines().iter().position(|line| {
7854 ftui::text::Line::spans(line)
7855 .iter()
7856 .any(|span| span.link.is_some())
7857 })?;
7858 (
7859 detail_text,
7860 line_index,
7861 usize::from(saturating_scroll_offset(self.detail_scroll_offset)),
7862 )
7863 }
7864 ViewMode::History => {
7865 self.history_selected_commit_url()?;
7866 let detail_text = self.history_detail_render_text();
7867 let line_index = self.history_detail_text().lines().count().saturating_add(1);
7868 (detail_text, line_index, 0)
7869 }
7870 _ => return None,
7871 };
7872 let line = detail_text.lines().get(line_index)?;
7873 let line_width = display_width(&line.to_plain_text());
7874 if line_width == 0 {
7875 return None;
7876 }
7877 if line_index < scroll_offset {
7878 return None;
7879 }
7880
7881 let width = u16::try_from(line_width)
7882 .unwrap_or(u16::MAX)
7883 .min(area.width);
7884 let visible_line_index = line_index.saturating_sub(scroll_offset);
7885 let y = area
7886 .y
7887 .saturating_add(saturating_scroll_offset(visible_line_index));
7888 if width == 0 || y >= area.y.saturating_add(area.height) {
7889 return None;
7890 }
7891
7892 Some(Rect::new(area.x, y, width, 1))
7893 }
7894
7895 fn detail_link_hit(&self, x: u16, y: u16) -> bool {
7896 if !matches!(self.focus, FocusPane::Detail) {
7897 return false;
7898 }
7899
7900 let Some(link_area) = self.current_detail_link_row_area() else {
7901 return false;
7902 };
7903 if !rect_contains(link_area, x, y) {
7904 return false;
7905 }
7906
7907 match self.mode {
7908 ViewMode::Main | ViewMode::Board | ViewMode::Insights | ViewMode::Graph => {
7909 self.selected_issue_external_ref_url().is_some()
7910 }
7911 ViewMode::History => self.history_selected_commit_url().is_some(),
7912 _ => false,
7913 }
7914 }
7915
7916 fn mouse_open_detail_link(&mut self, x: u16, y: u16) -> bool {
7917 if !self.detail_link_hit(x, y) {
7918 return false;
7919 }
7920
7921 match self.mode {
7922 ViewMode::Main | ViewMode::Board | ViewMode::Insights | ViewMode::Graph => {
7923 self.open_selected_issue_external_ref();
7924 }
7925 ViewMode::History => self.history_open_in_browser(),
7926 _ => return false,
7927 }
7928
7929 true
7930 }
7931
7932 fn mouse_copy_detail_link(&mut self, x: u16, y: u16) -> bool {
7933 if !self.detail_link_hit(x, y) {
7934 return false;
7935 }
7936
7937 match self.mode {
7938 ViewMode::Main | ViewMode::Board | ViewMode::Insights | ViewMode::Graph => {
7939 self.copy_selected_issue_external_ref();
7940 true
7941 }
7942 ViewMode::History => {
7943 self.history_copy_commit_url();
7944 true
7945 }
7946 _ => false,
7947 }
7948 }
7949
7950 fn issue_by_id(&self, issue_id: &str) -> Option<&Issue> {
7951 self.analyzer
7952 .issues
7953 .iter()
7954 .find(|issue| issue.id == issue_id)
7955 }
7956
7957 fn select_issue_by_id(&mut self, issue_id: &str) {
7958 if let Some(index) = self
7959 .analyzer
7960 .issues
7961 .iter()
7962 .position(|issue| issue.id == issue_id)
7963 {
7964 self.set_selected_index(index);
7965 self.ensure_selected_visible();
7966 }
7967 }
7968
7969 fn no_filtered_issues_text(&self, context: &str) -> String {
7970 format!(
7971 "No issues match the active filter ({}) in {context}.",
7972 self.list_filter.label()
7973 )
7974 }
7975
7976 fn handle_pages_wizard_key(&mut self, code: KeyCode, mut wiz: PagesWizardState) -> Cmd<Msg> {
7977 match code {
7978 KeyCode::Escape => {
7979 self.modal_overlay = None;
7980 return Cmd::None;
7981 }
7982 KeyCode::Backspace if wiz.step == 0 && !wiz.export_dir.is_empty() => {
7983 wiz.export_dir.pop();
7984 }
7985 KeyCode::Backspace if wiz.step == 1 && !wiz.title.is_empty() => {
7986 wiz.title.pop();
7987 }
7988 KeyCode::Backspace if wiz.step > 0 => {
7989 wiz.step -= 1;
7990 }
7991 KeyCode::Char('c') if wiz.step == 2 => {
7992 wiz.include_closed = !wiz.include_closed;
7993 }
7994 KeyCode::Char('h') if wiz.step == 2 => {
7995 wiz.include_history = !wiz.include_history;
7996 }
7997 KeyCode::Char(ch) if wiz.step == 0 => {
7998 wiz.export_dir.push(ch);
7999 }
8000 KeyCode::Char(ch) if wiz.step == 1 => {
8001 wiz.title.push(ch);
8002 }
8003 KeyCode::Enter => {
8004 if wiz.step >= PagesWizardState::step_count() - 1 {
8005 self.open_confirm_with_resume(
8006 "Export Pages?",
8007 "Finalize the current pages export settings?\n\nThis uses the reusable confirmation modal without changing the quit flow.",
8008 Some(ModalOverlay::PagesWizard(wiz)),
8009 );
8010 return Cmd::None;
8011 }
8012 wiz.step += 1;
8013 }
8014 _ => {}
8015 }
8016 self.modal_overlay = Some(ModalOverlay::PagesWizard(wiz));
8017 Cmd::None
8018 }
8019
8020 fn pages_wizard_text(wiz: &PagesWizardState) -> String {
8021 match wiz.step {
8022 0 => format!(
8023 "Export directory: {}\n\n\
8024 Type a path and press Enter to continue.\n\
8025 (Default: ./bv-pages)",
8026 wiz.export_dir
8027 ),
8028 1 => format!(
8029 "Page title: {}\n\n\
8030 Type a custom title and press Enter to continue.\n\
8031 (Leave blank for default: \"Project Issues\")",
8032 if wiz.title.is_empty() {
8033 "(default)"
8034 } else {
8035 &wiz.title
8036 }
8037 ),
8038 2 => format!(
8039 "Options:\n\n\
8040 [{}] Include closed issues (toggle: c)\n\
8041 [{}] Include history payload (toggle: h)\n\n\
8042 Press Enter to continue.",
8043 if wiz.include_closed { "x" } else { " " },
8044 if wiz.include_history { "x" } else { " " },
8045 ),
8046 3 => {
8047 let title_display = if wiz.title.is_empty() {
8048 "Project Issues"
8049 } else {
8050 &wiz.title
8051 };
8052 format!(
8053 "Review:\n\n\
8054 Directory: {}\n\
8055 Title: {title_display}\n\
8056 Include closed: {}\n\
8057 Include history: {}\n\n\
8058 Press Enter to export, Esc to cancel.",
8059 wiz.export_dir,
8060 if wiz.include_closed { "yes" } else { "no" },
8061 if wiz.include_history { "yes" } else { "no" },
8062 )
8063 }
8064 _ => String::new(),
8065 }
8066 }
8067
8068 fn help_overlay_text(&self, width: usize) -> String {
8069 struct Section {
8071 title: &'static str,
8072 bindings: Vec<(&'static str, &'static str)>,
8073 }
8074
8075 let sections = vec![
8076 Section {
8077 title: "Navigation",
8078 bindings: vec![
8079 ("j/k", "Move selection up/down"),
8080 ("arrows", "Move selection up/down"),
8081 ("h/l", "Lateral nav (lanes, peers)"),
8082 ("Ctrl+d/u", "Jump down/up by 10"),
8083 ("Ctrl+j/k", "Scroll detail pane"),
8084 ("Ctrl+←/→", "Resize active pane split"),
8085 ("Ctrl+0", "Reset pane splits"),
8086 ("PgUp/PgDn", "Jump by 10"),
8087 ("Home/End", "Jump to top/bottom"),
8088 ("gg", "Jump to top (any view)"),
8089 ("G", "Jump to bottom"),
8090 ("Ctrl+f/b", "Full page down/up"),
8091 ("Tab / Shift+Tab", "Toggle focus forward/back"),
8092 ("J/K", "Navigate deps in detail"),
8093 ("Enter", "Return to main / drill"),
8094 ("scroll", "Mouse wheel scrolls list"),
8095 ("splitter click/scroll", "Mouse-resize active divider"),
8096 ],
8097 },
8098 Section {
8099 title: "Views",
8100 bindings: vec![
8101 ("a", "Toggle actionable mode"),
8102 ("b", "Toggle board mode"),
8103 ("i", "Toggle insights mode"),
8104 ("g", "Toggle graph mode"),
8105 ("h", "Toggle history mode"),
8106 ("!", "Toggle attention mode"),
8107 ("T", "Toggle tree view"),
8108 ("[", "Toggle label dashboard"),
8109 ("]", "Toggle flow matrix"),
8110 ("v", "History: bead/git toggle"),
8111 ],
8112 },
8113 Section {
8114 title: "Filters",
8115 bindings: vec![
8116 ("o", "Filter: open only"),
8117 ("c", "Filter: closed only"),
8118 ("r", "Filter: ready only"),
8119 ("s", "Cycle sort/grouping/panel"),
8120 ],
8121 },
8122 Section {
8123 title: "Search",
8124 bindings: vec![
8125 ("/", "Start search"),
8126 ("n/N", "Next/prev search match"),
8127 ("Tab", "Cycle search mode (in /)"),
8128 ("Esc", "Cancel search"),
8129 ("Enter", "Confirm search"),
8130 ],
8131 },
8132 Section {
8133 title: "Actions",
8134 bindings: vec![
8135 ("p", "Toggle priority hints"),
8136 ("P", "Pages export wizard"),
8137 ("C", "Copy issue ID"),
8138 ("x", "Export issue markdown"),
8139 ("O", "Open in editor"),
8140 ("Ctrl+R/F5", "Refresh from disk"),
8141 ],
8142 },
8143 Section {
8144 title: "History",
8145 bindings: vec![
8146 ("c", "Cycle confidence filter"),
8147 ("y", "Copy SHA/ID"),
8148 ("o", "Open commit in browser"),
8149 ("f", "Toggle file tree"),
8150 ],
8151 },
8152 Section {
8153 title: "Board",
8154 bindings: vec![
8155 ("1-4", "Jump to lane"),
8156 ("H/L", "First/last lane"),
8157 ("0/$", "First/last in lane"),
8158 ("e", "Toggle empty lanes"),
8159 ],
8160 },
8161 Section {
8162 title: "Tree",
8163 bindings: vec![
8164 ("Enter/za", "Toggle fold"),
8165 ("zo/zc", "Open/close fold"),
8166 ("zR/zM", "Open/close all folds"),
8167 ("zz", "Recenter cursor"),
8168 ],
8169 },
8170 Section {
8171 title: "Insights",
8172 bindings: vec![
8173 ("s/S", "Cycle panel fwd/back"),
8174 ("m", "Toggle heatmap"),
8175 ("e", "Toggle explanations"),
8176 ("x", "Toggle calc-proof"),
8177 ],
8178 },
8179 Section {
8180 title: "Global",
8181 bindings: vec![
8182 ("?/F1", "Toggle this help"),
8183 ("Esc", "Back / clear / quit"),
8184 ("q", "Quit / back to main"),
8185 ("Ctrl+T", "Tutorial"),
8186 ("Ctrl+C", "Quit immediately"),
8187 ],
8188 },
8189 ];
8190
8191 let render_section = |sec: &Section| -> Vec<String> {
8193 let mut block = vec![format!("[{}]", sec.title)];
8194 for (key, desc) in &sec.bindings {
8195 block.push(format!(" {:<12} {}", key, desc));
8196 }
8197 block
8198 };
8199
8200 let rendered: Vec<Vec<String>> = sections.iter().map(render_section).collect();
8201
8202 let col_width = 36;
8204 let num_cols = if width >= col_width * 3 + 4 {
8205 3
8206 } else if width >= col_width * 2 + 2 {
8207 2
8208 } else {
8209 1
8210 };
8211
8212 if num_cols == 1 {
8213 let mut out = Vec::new();
8215 for block in &rendered {
8216 if !out.is_empty() {
8217 out.push(String::new());
8218 }
8219 out.extend(block.iter().cloned());
8220 }
8221 return out.join("\n");
8222 }
8223
8224 let total_lines: usize = rendered.iter().map(|b| b.len() + 1).sum::<usize>(); let target_per_col = (total_lines + num_cols - 1) / num_cols;
8227
8228 let mut columns: Vec<Vec<String>> = vec![Vec::new(); num_cols];
8229 let mut col = 0;
8230 let mut col_lines = 0;
8231
8232 for block in &rendered {
8233 let block_lines = block.len() + 1; if col_lines > 0 && col_lines + block_lines > target_per_col && col + 1 < num_cols {
8235 col += 1;
8236 col_lines = 0;
8237 }
8238 if !columns[col].is_empty() {
8239 columns[col].push(String::new());
8240 }
8241 columns[col].extend(block.iter().cloned());
8242 col_lines += block_lines;
8243 }
8244
8245 let max_rows = columns.iter().map(|c| c.len()).max().unwrap_or(0);
8247 let actual_col_width = width
8248 .saturating_sub(num_cols.saturating_sub(1))
8249 .checked_div(num_cols)
8250 .unwrap_or(width);
8251
8252 let mut output = Vec::with_capacity(max_rows);
8253 for row in 0..max_rows {
8254 let mut line = String::new();
8255 for (ci, col_data) in columns.iter().enumerate() {
8256 let cell = col_data.get(row).map(|s| s.as_str()).unwrap_or("");
8257 if ci > 0 {
8258 line.push_str(" | ");
8259 }
8260 let cell_trunc = truncate_display(cell, actual_col_width);
8261 line.push_str(&cell_trunc);
8262 if ci + 1 < columns.len() {
8264 let padding = actual_col_width.saturating_sub(display_width(&cell_trunc));
8265 for _ in 0..padding {
8266 line.push(' ');
8267 }
8268 }
8269 }
8270 output.push(line);
8271 }
8272
8273 output.join("\n")
8274 }
8275
8276 fn list_panel_text(&self) -> String {
8277 if self.analyzer.issues.is_empty() {
8278 return "(no issues loaded)".to_string();
8279 }
8280
8281 match self.mode {
8282 ViewMode::Board => self.board_list_text(),
8283 ViewMode::Insights => self.insights_list_text(),
8284 ViewMode::Graph => self.graph_list_text(),
8285 ViewMode::History => self.history_list_text(),
8286 ViewMode::Actionable => self.actionable_list_text(),
8287 ViewMode::Attention => self.attention_list_text(),
8288 ViewMode::Tree => self.tree_list_text(),
8289 ViewMode::LabelDashboard => self.label_dashboard_list_text(),
8290 ViewMode::FlowMatrix => self.flow_matrix_list_text(),
8291 ViewMode::TimeTravelDiff => self.time_travel_list_text(),
8292 ViewMode::Sprint => self.sprint_list_text(),
8293 ViewMode::Main => self.main_list_text(),
8294 }
8295 }
8296
8297 fn list_panel_render_text(&self, width: u16) -> RichText {
8298 match self.mode {
8299 ViewMode::Main => self.main_list_render_text(width),
8300 ViewMode::Graph => self.graph_list_render_text(width),
8301 ViewMode::Tree => self.tree_list_render_text(width),
8302 _ => RichText::raw(self.list_panel_text()),
8303 }
8304 }
8305
8306 fn main_list_text(&self) -> String {
8307 self.main_list_render_text(80).to_plain_text()
8308 }
8309
8310 fn main_list_empty_state_lines(&self) -> Vec<RichLine> {
8311 let mut lines = vec![RichLine::from_spans([RichSpan::styled(
8312 "No issues in the current triage slice",
8313 tokens::panel_title(),
8314 )])];
8315
8316 let mut scope = vec![format!("filter={}", self.list_filter.label())];
8317 if let Some(label) = self.modal_label_filter.as_deref() {
8318 scope.push(format!("label={label}"));
8319 }
8320 if let Some(repo) = self.modal_repo_filter.as_deref() {
8321 scope.push(format!("repo={repo}"));
8322 }
8323 if !self.main_search_query.is_empty() {
8324 scope.push(format!("search=/{}", self.main_search_query));
8325 }
8326 lines.push(RichLine::raw(format!("Scope: {}", scope.join(" | "))));
8327
8328 let recovery = if !self.main_search_query.is_empty() {
8329 "Recover: Esc keeps context | / edits search | n/N cycle hits | o/c/r/B/I switch filters"
8330 } else {
8331 "Recover: a all | o open | I in-progress | B blocked | c closed | r ready"
8332 };
8333 lines.push(RichLine::raw(recovery));
8334 lines
8335 }
8336
8337 fn main_focus_banner_line(&self) -> RichLine {
8338 let mut line = RichLine::new();
8339 push_chip(
8340 &mut line,
8341 if matches!(self.focus, FocusPane::List) {
8342 "Focus: list owns selection, / search, o/c/r/B/I filters, L label, w repo, Tab detail, Shift+Tab reverse"
8343 } else {
8344 "Focus: detail owns J/K deps, ^j/k scroll, o/y link actions, Tab returns to list, Shift+Tab reverse"
8345 },
8346 if matches!(self.focus, FocusPane::List) {
8347 SemanticTone::Accent
8348 } else {
8349 SemanticTone::Warning
8350 },
8351 );
8352 line
8353 }
8354
8355 fn main_scope_banner_line(&self) -> RichLine {
8356 let selected = self
8357 .selected_issue()
8358 .map_or_else(|| "none".to_string(), |issue| issue.id.clone());
8359 let visible = self.visible_issue_indices_for_list_nav();
8360 let position = self.selected_visible_slot(&visible).map_or_else(
8361 || "0/0".to_string(),
8362 |slot| format!("{}/{}", slot + 1, visible.len()),
8363 );
8364 let mut line = RichLine::new();
8365 push_metric_chip(
8366 &mut line,
8367 "scope",
8368 self.list_filter.label(),
8369 SemanticTone::Muted,
8370 );
8371 line.push_span(RichSpan::styled(" | ", tokens::dim()));
8372 push_metric_chip(
8373 &mut line,
8374 "label",
8375 self.modal_label_filter.as_deref().unwrap_or("any"),
8376 SemanticTone::Neutral,
8377 );
8378 line.push_span(RichSpan::styled(" | ", tokens::dim()));
8379 push_metric_chip(
8380 &mut line,
8381 "repo",
8382 self.modal_repo_filter.as_deref().unwrap_or("any"),
8383 SemanticTone::Neutral,
8384 );
8385 line.push_span(RichSpan::styled(" | ", tokens::dim()));
8386 push_metric_chip(&mut line, "pos", &position, SemanticTone::Muted);
8387 line.push_span(RichSpan::styled(" | ", tokens::dim()));
8388 push_metric_chip(
8389 &mut line,
8390 "search",
8391 if self.main_search_query.is_empty() {
8392 "off"
8393 } else {
8394 self.main_search_query.as_str()
8395 },
8396 if self.main_search_query.is_empty() {
8397 SemanticTone::Muted
8398 } else {
8399 SemanticTone::Accent
8400 },
8401 );
8402 line.push_span(RichSpan::styled(" | ", tokens::dim()));
8403 push_metric_chip(&mut line, "selected", &selected, SemanticTone::Warning);
8404 line
8405 }
8406
8407 fn main_search_banner_lines(&self) -> Vec<RichLine> {
8408 let mut lines = vec![self.main_focus_banner_line(), self.main_scope_banner_line()];
8409 if self.main_search_active {
8410 lines.push(RichLine::raw(format!(
8411 "Search (active): /{}",
8412 self.main_search_query
8413 )));
8414 } else if !self.main_search_query.is_empty() {
8415 lines.push(RichLine::raw(format!(
8416 "Search: /{} (n/N cycles)",
8417 self.main_search_query
8418 )));
8419 }
8420 let visible = self.visible_issue_indices();
8421 if !self.main_search_query.is_empty() {
8422 let matches = self.main_search_matches();
8423 if matches.is_empty() {
8424 lines.push(RichLine::raw("Matches: none in visible issues"));
8425 lines.push(RichLine::raw(
8426 "Hint: keep scanning rows, refine /query, or clear repo/label filters",
8427 ));
8428 } else {
8429 let position = self
8430 .main_search_match_cursor
8431 .min(matches.len().saturating_sub(1))
8432 + 1;
8433 lines.push(RichLine::raw(format!(
8434 "Matches: {position}/{}",
8435 matches.len()
8436 )));
8437 lines.push(RichLine::raw(
8438 "Guide: n/N cycle hits | Enter keeps query | Esc clears | Tab keeps context",
8439 ));
8440 }
8441 } else {
8442 lines.push(RichLine::raw(
8443 "Guide: / search-as-you-type | o/c/r/B/I quick filters | Esc unwinds state | Tab/Shift+Tab focus",
8444 ));
8445 }
8446 lines.push(RichLine::raw(""));
8447 if visible.is_empty() {
8448 lines.extend(self.main_list_empty_state_lines());
8449 }
8450 lines
8451 }
8452
8453 fn main_list_render_text(&self, width: u16) -> RichText {
8454 let visible = self.visible_issue_indices();
8455 let mut lines = self.main_search_banner_lines();
8456 if visible.is_empty() {
8457 return RichText::from_lines(lines);
8458 }
8459
8460 let line_width = usize::from(width.saturating_sub(2)).max(24);
8461 let search_matches = self.main_search_matches();
8462 let search_positions = search_matches
8463 .iter()
8464 .enumerate()
8465 .map(|(slot, index)| (*index, slot + 1))
8466 .collect::<BTreeMap<usize, usize>>();
8467 for (slot, (index, issue)) in visible
8468 .into_iter()
8469 .filter_map(|index| self.analyzer.issues.get(index).map(|issue| (index, issue)))
8470 .enumerate()
8471 {
8472 let open_blockers = self.analyzer.graph.open_blockers(&issue.id).len();
8473 let blocks_count = self
8474 .analyzer
8475 .metrics
8476 .blocks_count
8477 .get(&issue.id)
8478 .copied()
8479 .unwrap_or_default();
8480 let pagerank_rank = metric_rank(&self.analyzer.metrics.pagerank, &issue.id);
8481 let graph_score = self
8482 .analyzer
8483 .metrics
8484 .pagerank
8485 .get(&issue.id)
8486 .copied()
8487 .unwrap_or_default();
8488 let critical_depth = self
8489 .analyzer
8490 .metrics
8491 .critical_depth
8492 .get(&issue.id)
8493 .copied()
8494 .unwrap_or_default();
8495 lines.push(issue_scan_line(
8496 issue,
8497 index == self.selected,
8498 ScanLineContext {
8499 open_blockers,
8500 blocks_count,
8501 triage_rank: slot + 1,
8502 pagerank_rank,
8503 critical_depth,
8504 graph_score,
8505 search_match_position: search_positions.get(&index).copied(),
8506 total_search_matches: search_matches.len(),
8507 diff_tag: self.issue_diff_tag(&issue.id),
8508 available_width: line_width,
8509 },
8510 ));
8511 }
8512
8513 RichText::from_lines(lines)
8514 }
8515
8516 fn board_list_text(&self) -> String {
8517 let lanes = self.board_lane_indices();
8518 let mut out = Vec::<String>::new();
8519 out.push(format!(
8520 "Grouping: {} (s cycles) | Empty: {} (e)",
8521 self.board_grouping.label(),
8522 self.board_empty_visibility.label(),
8523 ));
8524 if self.board_search_active {
8525 out.push(format!("Search (active): /{}", self.board_search_query));
8526 } else if !self.board_search_query.is_empty() {
8527 out.push(format!("Search: /{} (n/N cycles)", self.board_search_query));
8528 }
8529 if !self.board_search_query.is_empty() {
8530 let matches = self.board_search_matches();
8531 if matches.is_empty() {
8532 out.push("Matches: none".to_string());
8533 } else {
8534 let position = self
8535 .board_search_match_cursor
8536 .min(matches.len().saturating_sub(1))
8537 + 1;
8538 out.push(format!("Matches: {position}/{}", matches.len()));
8539 }
8540 }
8541
8542 let sel_id = self.selected_issue().map(|i| i.id.clone());
8544 let sel_index = self.selected;
8545
8546 out.push(String::new());
8547 let total: usize = lanes.iter().map(|(_, v)| v.len()).sum();
8548 out.push(format!("Lanes ({}) | {} issues total", lanes.len(), total));
8549 out.push(String::new());
8550
8551 for (lane, lane_indices) in &lanes {
8552 let count = lane_indices.len();
8553 let is_current_lane = sel_id.as_ref().is_some_and(|sid| {
8555 lane_indices
8556 .iter()
8557 .any(|&i| self.analyzer.issues[i].id == *sid)
8558 });
8559 let marker = if is_current_lane { "▸" } else { " " };
8560
8561 let blocked_count = lane_indices
8563 .iter()
8564 .filter(|&&i| {
8565 self.analyzer.issues[i]
8566 .normalized_status()
8567 .eq_ignore_ascii_case("blocked")
8568 })
8569 .count();
8570 let health = if count == 0 {
8571 "empty".to_string()
8572 } else if blocked_count > 0 {
8573 format!("{blocked_count} blocked")
8574 } else {
8575 "clear".to_string()
8576 };
8577
8578 let bar_len = count.min(20);
8580 let bar: String = std::iter::repeat_n('\u{2588}', bar_len).collect();
8581 out.push(format!(
8582 "{marker} \u{250c}\u{2500} {lane} [{count}] {bar} {health}"
8583 ));
8584
8585 let preview_limit = 8;
8587 for &idx in lane_indices.iter().take(preview_limit) {
8588 let issue = &self.analyzer.issues[idx];
8589 let is_sel = idx == sel_index;
8590 let s_icon = status_icon(&issue.status);
8591 let t_icon = type_icon(&issue.issue_type);
8592 let open_bl = self.analyzer.graph.open_blockers(&issue.id).len();
8593 let blocks = self
8594 .analyzer
8595 .metrics
8596 .blocks_count
8597 .get(&issue.id)
8598 .copied()
8599 .unwrap_or(0);
8600 let dep_tag = if open_bl > 0 {
8601 format!("\u{2298}{open_bl}")
8602 } else if blocks > 0 {
8603 format!("\u{2193}{blocks}")
8604 } else {
8605 String::new()
8606 };
8607 let assignee = if issue.assignee.is_empty() {
8608 String::new()
8609 } else {
8610 format!(" @{}", truncate_str(&issue.assignee, 8))
8611 };
8612
8613 let sel_char = if is_sel { "\u{25b6}" } else { "\u{2502}" };
8615 out.push(format!(
8616 " {sel_char} {s_icon}{t_icon} P{} {} {}{dep_tag}{assignee}",
8617 issue.priority.clamp(0, 4),
8618 truncate_str(&issue.id, 10),
8619 truncate_str(&issue.title, 18),
8620 ));
8621 }
8622 if lane_indices.len() > preview_limit {
8623 out.push(format!(
8624 " \u{2502} ... +{} more",
8625 lane_indices.len() - preview_limit
8626 ));
8627 }
8628 if lane_indices.is_empty() {
8629 out.push(" \u{2502} (empty)".to_string());
8630 }
8631 out.push(format!(
8632 " \u{2514}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}"
8633 ));
8634 out.push(String::new());
8635 }
8636
8637 out.join("\n")
8638 }
8639
8640 fn insights_list_text(&self) -> String {
8641 if let Some(state) = self.insights_heatmap.as_ref() {
8642 let data = self.insights_heatmap_data();
8643 let row = state
8644 .row
8645 .min(INSIGHTS_HEATMAP_DEPTH_LABELS.len().saturating_sub(1));
8646 let col = state
8647 .col
8648 .min(INSIGHTS_HEATMAP_SCORE_LABELS.len().saturating_sub(1));
8649 let cell_issue_ids = data.issue_ids[row][col].clone();
8650
8651 if state.drill_active {
8652 let mut lines = vec![
8653 "Priority heatmap drill | m toggle | j/k issue | Tab detail | Esc back"
8654 .to_string(),
8655 format!(
8656 "Cell: {} x {} ({} issue(s))",
8657 INSIGHTS_HEATMAP_DEPTH_LABELS[row],
8658 INSIGHTS_HEATMAP_SCORE_LABELS[col],
8659 cell_issue_ids.len()
8660 ),
8661 String::new(),
8662 ];
8663
8664 if cell_issue_ids.is_empty() {
8665 lines.push(" (no issues in selected cell)".to_string());
8666 return lines.join("\n");
8667 }
8668
8669 let max_visible = 10usize;
8670 let cursor = state
8671 .drill_cursor
8672 .min(cell_issue_ids.len().saturating_sub(1));
8673 let start = cursor.saturating_sub(max_visible.saturating_sub(1));
8674 let end = (start + max_visible).min(cell_issue_ids.len());
8675
8676 for (idx, issue_id) in cell_issue_ids[start..end].iter().enumerate() {
8677 let actual = start + idx;
8678 let marker = if actual == cursor { '>' } else { ' ' };
8679 let (priority, title) = self
8680 .analyzer
8681 .issues
8682 .iter()
8683 .find(|issue| issue.id == *issue_id)
8684 .map_or((9, String::new()), |issue| {
8685 (issue.priority, truncate_str(&issue.title, 28))
8686 });
8687 lines.push(format!("{marker} {issue_id:<12} p{priority} {title}"));
8688 }
8689
8690 if cell_issue_ids.len() > max_visible {
8691 lines.push(String::new());
8692 lines.push(format!(
8693 "Drill cursor: {}/{}",
8694 cursor + 1,
8695 cell_issue_ids.len()
8696 ));
8697 }
8698
8699 return lines.join("\n");
8700 }
8701
8702 let mut lines = vec![
8703 "Priority heatmap | m toggle | h/l/j/k cell | Enter drill | Tab detail".to_string(),
8704 String::new(),
8705 ];
8706
8707 if data
8708 .counts
8709 .iter()
8710 .all(|row_counts| row_counts.iter().all(|count| *count == 0))
8711 {
8712 lines.push(" (no open, filter-matching issues to chart)".to_string());
8713 return lines.join("\n");
8714 }
8715
8716 let col_totals = (0..INSIGHTS_HEATMAP_SCORE_LABELS.len())
8717 .map(|score_col| {
8718 data.counts
8719 .iter()
8720 .map(|row_counts| row_counts[score_col])
8721 .sum()
8722 })
8723 .collect::<Vec<usize>>();
8724
8725 lines.push(format!(
8726 "{:<8} | {:>4} {:>4} {:>4} {:>4} {:>4} | {:>4}",
8727 "Depth",
8728 INSIGHTS_HEATMAP_SCORE_LABELS[0],
8729 INSIGHTS_HEATMAP_SCORE_LABELS[1],
8730 INSIGHTS_HEATMAP_SCORE_LABELS[2],
8731 INSIGHTS_HEATMAP_SCORE_LABELS[3],
8732 INSIGHTS_HEATMAP_SCORE_LABELS[4],
8733 "Tot"
8734 ));
8735 lines.push("-".repeat(46));
8736
8737 for (depth_row, label) in INSIGHTS_HEATMAP_DEPTH_LABELS.iter().enumerate() {
8738 let row_total = data.counts[depth_row].iter().sum::<usize>();
8739 let mut cell_chunks = Vec::with_capacity(INSIGHTS_HEATMAP_SCORE_LABELS.len());
8740 for score_col in 0..INSIGHTS_HEATMAP_SCORE_LABELS.len() {
8741 let count = data.counts[depth_row][score_col];
8742 let cell = if depth_row == row && score_col == col {
8743 if count == 0 {
8744 "[ .]".to_string()
8745 } else {
8746 format!("[{count:>2}]")
8747 }
8748 } else if count == 0 {
8749 " . ".to_string()
8750 } else {
8751 format!(" {count:>2} ")
8752 };
8753 cell_chunks.push(cell);
8754 }
8755 lines.push(format!(
8756 "{label:<8} | {} | {row_total:>4}",
8757 cell_chunks.join(" ")
8758 ));
8759 }
8760
8761 lines.push("-".repeat(46));
8762 lines.push(format!(
8763 "{:<8} | {:>4} {:>4} {:>4} {:>4} {:>4} | {:>4}",
8764 "Total",
8765 col_totals[0],
8766 col_totals[1],
8767 col_totals[2],
8768 col_totals[3],
8769 col_totals[4],
8770 col_totals.iter().sum::<usize>()
8771 ));
8772 lines.push(String::new());
8773 lines.push(format!(
8774 "Selected: {} x {} ({} issue(s))",
8775 INSIGHTS_HEATMAP_DEPTH_LABELS[row],
8776 INSIGHTS_HEATMAP_SCORE_LABELS[col],
8777 cell_issue_ids.len()
8778 ));
8779 if let Some(issue_id) = cell_issue_ids.first() {
8780 let issue_title = self
8781 .analyzer
8782 .issues
8783 .iter()
8784 .find(|issue| issue.id == *issue_id)
8785 .map_or("", |issue| issue.title.as_str());
8786 lines.push(format!(
8787 "Lead issue: {issue_id} {}",
8788 truncate_str(issue_title, 34)
8789 ));
8790 } else {
8791 lines.push("Lead issue: none in selected cell".to_string());
8792 }
8793
8794 return lines.join("\n");
8795 }
8796
8797 let insights = self.analyzer.insights();
8798
8799 let mut lines = vec![format!(
8800 "[{}] s/S cycles panel | e explanations | x calc-proof | / search | m heatmap",
8801 self.insights_panel.label()
8802 )];
8803 if self.insights_search_active {
8804 lines.push(format!("Search (active): /{}", self.insights_search_query));
8805 } else if !self.insights_search_query.is_empty() {
8806 lines.push(format!(
8807 "Search: /{} (n/N cycles)",
8808 self.insights_search_query
8809 ));
8810 }
8811 if !self.insights_search_query.is_empty() {
8812 let matches = self.insights_search_matches();
8813 if matches.is_empty() {
8814 lines.push("Matches: none".to_string());
8815 } else {
8816 let position = self
8817 .insights_search_match_cursor
8818 .min(matches.len().saturating_sub(1))
8819 + 1;
8820 lines.push(format!("Matches: {position}/{}", matches.len()));
8821 }
8822 }
8823 lines.push(String::new());
8824 let search_matches = self.insights_search_matches();
8825 let search_positions = search_matches
8826 .iter()
8827 .enumerate()
8828 .map(|(slot, index)| (*index, slot + 1))
8829 .collect::<BTreeMap<usize, usize>>();
8830 lines.extend(self.insights_signal_tiles());
8831 lines.push(String::new());
8832 lines.extend(self.insights_outlier_radar());
8833 lines.push(String::new());
8834 lines.push(format!(
8835 "Panel Focus | {} | {}",
8836 self.insights_panel.label(),
8837 self.insights_panel_focus_hint()
8838 ));
8839 lines.push(String::new());
8840
8841 match self.insights_panel {
8842 InsightsPanel::Bottlenecks => {
8843 if insights.bottlenecks.is_empty() {
8844 lines.push(" (no open issues to rank)".to_string());
8845 } else {
8846 lines.extend(insights.bottlenecks.iter().take(15).enumerate().map(
8847 |(index, item)| {
8848 let hit_suffix = self.insights_search_hit_suffix(
8849 &item.id,
8850 &search_positions,
8851 search_matches.len(),
8852 );
8853 format!(
8854 " {}. {:<12} score={:.3} blocks={}{}",
8855 index + 1,
8856 item.id,
8857 item.score,
8858 item.blocks_count,
8859 hit_suffix
8860 )
8861 },
8862 ));
8863 }
8864 }
8865 InsightsPanel::Keystones => {
8866 let mut keystones = self
8867 .analyzer
8868 .issues
8869 .iter()
8870 .filter(|issue| issue.is_open_like())
8871 .filter_map(|issue| {
8872 self.analyzer
8873 .metrics
8874 .critical_depth
8875 .get(&issue.id)
8876 .copied()
8877 .map(|depth| (issue.id.as_str(), depth))
8878 })
8879 .filter(|(_, depth)| *depth > 0)
8880 .collect::<Vec<_>>();
8881
8882 keystones
8883 .sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(right.0)));
8884
8885 if keystones.is_empty() {
8886 lines.push(" (no foundational chain detected)".to_string());
8887 } else {
8888 lines.extend(keystones.iter().take(15).enumerate().map(
8889 |(index, (id, depth))| {
8890 let unblocks = self
8891 .analyzer
8892 .metrics
8893 .blocks_count
8894 .get(*id)
8895 .copied()
8896 .unwrap_or_default();
8897 let hit_suffix = self.insights_search_hit_suffix(
8898 id,
8899 &search_positions,
8900 search_matches.len(),
8901 );
8902 format!(
8903 " {}. {:<12} depth={} unblocks={}{}",
8904 index + 1,
8905 id,
8906 depth,
8907 unblocks,
8908 hit_suffix
8909 )
8910 },
8911 ));
8912 }
8913 }
8914 InsightsPanel::CriticalPath => {
8915 if insights.critical_path.is_empty() {
8916 lines.push(" (no critical path detected)".to_string());
8917 } else {
8918 lines.extend(
8919 insights
8920 .critical_path
8921 .iter()
8922 .enumerate()
8923 .map(|(index, id)| {
8924 let depth = self
8925 .analyzer
8926 .metrics
8927 .critical_depth
8928 .get(id)
8929 .copied()
8930 .unwrap_or_default();
8931 let hit_suffix = self.insights_search_hit_suffix(
8932 id,
8933 &search_positions,
8934 search_matches.len(),
8935 );
8936 format!(" {}. {:<12} depth={}{}", index + 1, id, depth, hit_suffix)
8937 }),
8938 );
8939 }
8940 }
8941 InsightsPanel::Influencers => {
8942 self.append_metric_items(
8943 &mut lines,
8944 &insights.influencers,
8945 "influencer",
8946 &search_positions,
8947 search_matches.len(),
8948 );
8949 }
8950 InsightsPanel::Betweenness => {
8951 self.append_metric_items(
8952 &mut lines,
8953 &insights.betweenness,
8954 "betweenness",
8955 &search_positions,
8956 search_matches.len(),
8957 );
8958 }
8959 InsightsPanel::Hubs => {
8960 self.append_metric_items(
8961 &mut lines,
8962 &insights.hubs,
8963 "hub-score",
8964 &search_positions,
8965 search_matches.len(),
8966 );
8967 }
8968 InsightsPanel::Authorities => {
8969 self.append_metric_items(
8970 &mut lines,
8971 &insights.authorities,
8972 "authority",
8973 &search_positions,
8974 search_matches.len(),
8975 );
8976 }
8977 InsightsPanel::Cores => {
8978 if insights.cores.is_empty() {
8979 lines.push(" (no k-core data)".to_string());
8980 } else {
8981 lines.extend(insights.cores.iter().take(15).enumerate().map(
8982 |(index, item)| {
8983 let hit_suffix = self.insights_search_hit_suffix(
8984 &item.id,
8985 &search_positions,
8986 search_matches.len(),
8987 );
8988 format!(
8989 " {}. {:<12} k={}{}",
8990 index + 1,
8991 item.id,
8992 item.value,
8993 hit_suffix
8994 )
8995 },
8996 ));
8997 }
8998 }
8999 InsightsPanel::CutPoints => {
9000 if insights.articulation_points.is_empty() {
9001 lines.push(" (no cut points -- graph is well-connected)".to_string());
9002 } else {
9003 lines.extend(insights.articulation_points.iter().enumerate().map(
9004 |(index, id)| {
9005 let hit_suffix = self.insights_search_hit_suffix(
9006 id,
9007 &search_positions,
9008 search_matches.len(),
9009 );
9010 format!(" {}. {}{}", index + 1, id, hit_suffix)
9011 },
9012 ));
9013 }
9014 }
9015 InsightsPanel::Slack => {
9016 if insights.slack.is_empty() {
9017 lines
9018 .push(" (no zero-slack issues -- all have scheduling buffer)".to_string());
9019 } else {
9020 lines.extend(insights.slack.iter().enumerate().map(|(index, id)| {
9021 let hit_suffix = self.insights_search_hit_suffix(
9022 id,
9023 &search_positions,
9024 search_matches.len(),
9025 );
9026 format!(" {}. {}{}", index + 1, id, hit_suffix)
9027 }));
9028 }
9029 }
9030 InsightsPanel::Cycles => {
9031 if insights.cycles.is_empty() {
9032 lines.push(" No cycles detected".to_string());
9033 } else {
9034 lines.extend(
9035 insights.cycles.iter().enumerate().map(|(index, cycle)| {
9036 format!(" {}. {}", index + 1, cycle.join(" -> "))
9037 }),
9038 );
9039 }
9040 }
9041 InsightsPanel::Priority => {
9042 let recommendations = self.analyzer.priority(0.0, 15, None, None);
9043 if recommendations.is_empty() {
9044 lines.push(" (no priority recommendations available)".to_string());
9045 } else {
9046 lines.extend(recommendations.iter().enumerate().map(|(index, item)| {
9047 let hit_suffix = self.insights_search_hit_suffix(
9048 &item.id,
9049 &search_positions,
9050 search_matches.len(),
9051 );
9052 format!(
9053 " {}. {:<12} score={:.3} unblocks={} p{}{}",
9054 index + 1,
9055 item.id,
9056 item.score,
9057 item.unblocks,
9058 item.priority,
9059 hit_suffix
9060 )
9061 }));
9062 }
9063 }
9064 }
9065
9066 lines.join("\n")
9067 }
9068
9069 fn insights_signal_tiles(&self) -> Vec<String> {
9070 let insights = self.analyzer.insights();
9071 let open_issues = self
9072 .analyzer
9073 .issues
9074 .iter()
9075 .filter(|issue| issue.is_open_like())
9076 .count();
9077 let blocked_open = self
9078 .analyzer
9079 .issues
9080 .iter()
9081 .filter(|issue| {
9082 issue.is_open_like() && !self.analyzer.graph.open_blockers(&issue.id).is_empty()
9083 })
9084 .count();
9085 let zero_slack = insights.slack.len();
9086 let max_k_core = insights
9087 .cores
9088 .iter()
9089 .map(|item| item.value)
9090 .max()
9091 .unwrap_or(0);
9092
9093 vec![
9094 "Signal Tiles".to_string(),
9095 format!(
9096 "[Flow ] open={open_issues} blocked={blocked_open} crit-path={} cycles={}",
9097 insights.critical_path.len(),
9098 insights.cycles.len()
9099 ),
9100 format!(
9101 "[Risk ] bottlenecks={} cut-points={} zero-slack={} max-k={max_k_core}",
9102 insights.bottlenecks.len(),
9103 insights.articulation_points.len(),
9104 zero_slack
9105 ),
9106 ]
9107 }
9108
9109 fn insights_outlier_radar(&self) -> Vec<String> {
9110 let insights = self.analyzer.insights();
9111 let priority = self.analyzer.priority(0.0, 1, None, None);
9112 let top_bottleneck = insights.bottlenecks.first().map_or_else(
9113 || "none".to_string(),
9114 |item| {
9115 format!(
9116 "{} score={:.3} blocks={}",
9117 item.id, item.score, item.blocks_count
9118 )
9119 },
9120 );
9121 let top_influencer = insights.influencers.first().map_or_else(
9122 || "none".to_string(),
9123 |item| format!("{} pr={:.4}", item.id, item.value),
9124 );
9125 let top_priority = priority.first().map_or_else(
9126 || "none".to_string(),
9127 |item| format!("{} score={:.3} p{}", item.id, item.score, item.priority),
9128 );
9129
9130 vec![
9131 "Outlier Radar".to_string(),
9132 format!("[Lead ] bottleneck={top_bottleneck}"),
9133 format!("[Rank ] influencer={top_influencer}"),
9134 format!("[Act ] next-priority={top_priority}"),
9135 ]
9136 }
9137
9138 fn insights_panel_focus_hint(&self) -> &'static str {
9139 match self.insights_panel {
9140 InsightsPanel::Bottlenecks => "blocking pressure and downstream drag",
9141 InsightsPanel::Keystones => "foundational chains that unlock follow-on work",
9142 InsightsPanel::CriticalPath => "deep dependency rails with little slack",
9143 InsightsPanel::Influencers => "highest graph influence by PageRank",
9144 InsightsPanel::Betweenness => "bridge nodes that route dependency flow",
9145 InsightsPanel::Hubs => "strong outbound influence in the graph",
9146 InsightsPanel::Authorities => "strong inbound authority in the graph",
9147 InsightsPanel::Cores => "densest cohesion clusters",
9148 InsightsPanel::CutPoints => "single-node fragility in connectivity",
9149 InsightsPanel::Slack => "zero-buffer scheduling hotspots",
9150 InsightsPanel::Cycles => "circular dependency traps",
9151 InsightsPanel::Priority => "graph-informed reprioritization candidates",
9152 }
9153 }
9154
9155 fn insights_search_hit_suffix(
9156 &self,
9157 issue_id: &str,
9158 search_positions: &BTreeMap<usize, usize>,
9159 total_search_matches: usize,
9160 ) -> String {
9161 self.issue_index_for_id(issue_id)
9162 .and_then(|issue_index| search_positions.get(&issue_index).copied())
9163 .map_or_else(String::new, |position| {
9164 format!(" hit {position}/{total_search_matches}")
9165 })
9166 }
9167
9168 fn append_metric_items(
9169 &self,
9170 lines: &mut Vec<String>,
9171 items: &[crate::analysis::MetricItem],
9172 label: &str,
9173 search_positions: &BTreeMap<usize, usize>,
9174 total_search_matches: usize,
9175 ) {
9176 if items.is_empty() {
9177 lines.push(format!(" (no {label} data)"));
9178 } else {
9179 lines.extend(items.iter().take(15).enumerate().map(|(index, item)| {
9180 let scaled = (item.value * 20.0).clamp(0.0, 20.0);
9181 let bar_len = (1_u32..=20_u32)
9182 .take_while(|threshold| scaled >= f64::from(*threshold))
9183 .count();
9184 let bar = format!(
9185 "{}{}",
9186 "#".repeat(bar_len),
9187 ".".repeat(20_usize.saturating_sub(bar_len))
9188 );
9189 let hit_suffix = self.insights_search_hit_suffix(
9190 &item.id,
9191 search_positions,
9192 total_search_matches,
9193 );
9194 format!(
9195 " {}. {:<12} [{bar}] {:.4}{hit_suffix}",
9196 index + 1,
9197 item.id,
9198 item.value
9199 )
9200 }));
9201 }
9202 }
9203
9204 fn graph_node_score(&self, id: &str) -> f64 {
9205 let depth = self
9206 .analyzer
9207 .metrics
9208 .critical_depth
9209 .get(id)
9210 .copied()
9211 .unwrap_or_default() as f64;
9212 let pagerank = self
9213 .analyzer
9214 .metrics
9215 .pagerank
9216 .get(id)
9217 .copied()
9218 .unwrap_or_default();
9219 depth + pagerank
9220 }
9221
9222 fn graph_list_text(&self) -> String {
9223 let visible = self.graph_visible_issue_indices();
9224 if visible.is_empty() {
9225 return format!("(no issues match filter: {})", self.list_filter.label());
9226 }
9227
9228 let total = visible.len();
9229 let mut lines = vec![format!(
9230 "Nodes ({total}) by critical-path score | h/l nav | / search | Tab focus"
9231 )];
9232 if self.graph_search_active {
9233 lines.push(format!("Search (active): /{}", self.graph_search_query));
9234 } else if !self.graph_search_query.is_empty() {
9235 lines.push(format!("Search: /{} (n/N cycles)", self.graph_search_query));
9236 }
9237 if !self.graph_search_query.is_empty() {
9238 let matches = self.graph_search_matches();
9239 if matches.is_empty() {
9240 lines.push("Matches: none".to_string());
9241 } else {
9242 let position = self
9243 .graph_search_match_cursor
9244 .min(matches.len().saturating_sub(1))
9245 + 1;
9246 lines.push(format!("Matches: {position}/{}", matches.len()));
9247 }
9248 }
9249 lines.push(String::new());
9250 let search_matches = self.graph_search_matches();
9251 let search_positions = search_matches
9252 .iter()
9253 .enumerate()
9254 .map(|(slot, index)| (*index, slot + 1))
9255 .collect::<BTreeMap<usize, usize>>();
9256
9257 lines.extend(
9258 visible
9259 .into_iter()
9260 .filter_map(|index| self.analyzer.issues.get(index).map(|issue| (index, issue)))
9261 .map(|(index, issue)| {
9262 let marker = if index == self.selected { '>' } else { ' ' };
9263 let si = status_icon(&issue.status);
9264 let blocks = self
9265 .analyzer
9266 .metrics
9267 .blocks_count
9268 .get(&issue.id)
9269 .copied()
9270 .unwrap_or_default();
9271 let blocked_by = self
9272 .analyzer
9273 .metrics
9274 .blocked_by_count
9275 .get(&issue.id)
9276 .copied()
9277 .unwrap_or_default();
9278 let pagerank = self
9279 .analyzer
9280 .metrics
9281 .pagerank
9282 .get(&issue.id)
9283 .copied()
9284 .unwrap_or_default();
9285 let hit_suffix = search_positions
9286 .get(&index)
9287 .map_or_else(String::new, |position| {
9288 format!(" hit {position}/{}", search_matches.len())
9289 });
9290 format!(
9291 "{marker} {si} {:<12} in:{:>2} out:{:>2} pr:{:.3}{hit_suffix}",
9292 issue.id, blocked_by, blocks, pagerank
9293 )
9294 }),
9295 );
9296 lines.join("\n")
9297 }
9298
9299 fn graph_list_render_text(&self, width: u16) -> RichText {
9300 let visible = self.graph_visible_issue_indices();
9301 if visible.is_empty() {
9302 return RichText::raw(format!(
9303 "(no issues match filter: {})",
9304 self.list_filter.label()
9305 ));
9306 }
9307
9308 let total = visible.len();
9309 let mut lines = vec![panel_header(
9310 "Nodes",
9311 Some(&format!(
9312 "{total} by critical-path score | h/l nav | / search | Tab focus"
9313 )),
9314 )];
9315 if self.graph_search_active {
9316 lines.push(RichLine::raw(format!(
9317 "Search (active): /{}",
9318 self.graph_search_query
9319 )));
9320 } else if !self.graph_search_query.is_empty() {
9321 lines.push(RichLine::raw(format!(
9322 "Search: /{} (n/N cycles)",
9323 self.graph_search_query
9324 )));
9325 }
9326 if !self.graph_search_query.is_empty() {
9327 let matches = self.graph_search_matches();
9328 if matches.is_empty() {
9329 lines.push(RichLine::raw("Matches: none"));
9330 } else {
9331 let position = self
9332 .graph_search_match_cursor
9333 .min(matches.len().saturating_sub(1))
9334 + 1;
9335 lines.push(RichLine::raw(format!(
9336 "Matches: {position}/{}",
9337 matches.len()
9338 )));
9339 }
9340 }
9341 lines.push(section_separator(
9342 usize::from(width.saturating_sub(2)).max(24),
9343 ));
9344
9345 let pr_max = max_metric_value(&self.analyzer.metrics.pagerank);
9346 let line_width = usize::from(width.saturating_sub(2)).max(24);
9347 let search_matches = self.graph_search_matches();
9348 let search_positions = search_matches
9349 .iter()
9350 .enumerate()
9351 .map(|(slot, index)| (*index, slot + 1))
9352 .collect::<BTreeMap<usize, usize>>();
9353 for index in visible {
9354 let Some(issue) = self.analyzer.issues.get(index) else {
9355 continue;
9356 };
9357 let blocked_by = self.analyzer.graph.open_blockers(&issue.id).len();
9358 let blocks = self
9359 .analyzer
9360 .metrics
9361 .blocks_count
9362 .get(&issue.id)
9363 .copied()
9364 .unwrap_or_default();
9365 let pagerank = self
9366 .analyzer
9367 .metrics
9368 .pagerank
9369 .get(&issue.id)
9370 .copied()
9371 .unwrap_or_default();
9372 let mut line = RichLine::new();
9373 let marker_style = if index == self.selected {
9374 tokens::selected()
9375 } else {
9376 tokens::dim()
9377 };
9378 line.push_span(RichSpan::styled(
9379 if index == self.selected { "▸" } else { " " },
9380 marker_style,
9381 ));
9382 line.push_span(RichSpan::raw(" "));
9383 line.push_span(RichSpan::styled(
9384 truncate_display(&issue.id, 12),
9385 tokens::panel_title(),
9386 ));
9387 line.push_span(RichSpan::raw(" "));
9388 for span in metric_strip("PR", pagerank, pr_max) {
9389 line.push_span(span);
9390 }
9391 let bl = blocker_indicator(blocked_by, blocks);
9393 if !bl.is_empty() {
9394 line.push_span(RichSpan::raw(" "));
9395 for s in bl {
9396 line.push_span(s);
9397 }
9398 }
9399 if self
9401 .analyzer
9402 .metrics
9403 .cycles
9404 .iter()
9405 .any(|c| c.contains(&issue.id))
9406 {
9407 line.push_span(RichSpan::styled(
9408 " \u{27f3}",
9409 tokens::status_style("blocked"),
9410 ));
9411 }
9412 if self
9414 .analyzer
9415 .metrics
9416 .articulation_points
9417 .contains(&issue.id)
9418 {
9419 line.push_span(RichSpan::styled(
9420 " \u{25c6}",
9421 tokens::status_style("in_progress"),
9422 ));
9423 }
9424 line.push_span(RichSpan::raw(" "));
9425 let title_width = line_width.saturating_sub(42);
9426 line.push_span(RichSpan::styled(
9427 truncate_display(&issue.title, title_width.max(8)),
9428 tokens::help_desc(),
9429 ));
9430 if let Some(position) = search_positions.get(&index) {
9431 line.push_span(RichSpan::raw(" "));
9432 line.push_span(RichSpan::styled(
9433 format!("hit {position}/{}", search_matches.len()),
9434 tokens::status_style("selected"),
9435 ));
9436 }
9437 lines.push(line);
9438 }
9439
9440 RichText::from_lines(lines)
9441 }
9442
9443 fn graph_visible_issue_indices(&self) -> Vec<usize> {
9444 let mut visible = self.visible_issue_indices();
9445 visible.sort_by(|&left_idx, &right_idx| {
9446 let left = &self.analyzer.issues[left_idx];
9447 let right = &self.analyzer.issues[right_idx];
9448 let left_score = self.graph_node_score(&left.id);
9449 let right_score = self.graph_node_score(&right.id);
9450 right_score
9451 .total_cmp(&left_score)
9452 .then_with(|| left.id.cmp(&right.id))
9453 });
9454 visible
9455 }
9456
9457 fn history_list_text(&self) -> String {
9458 if matches!(self.history_view_mode, HistoryViewMode::Git) {
9459 let query = self.history_search_query.trim();
9460 let visible = self.history_git_visible_commit_indices();
9461 let cache = self.history_git_cache.as_ref();
9462
9463 if visible.is_empty() {
9464 if query.is_empty() {
9465 return "No git commits correlated with beads.\n\
9466 (ensure repo has commits referencing bead IDs)"
9467 .to_string();
9468 }
9469 return format!("(no commits match search: /{query})");
9470 }
9471
9472 let cursor = self
9473 .history_event_cursor
9474 .min(visible.len().saturating_sub(1));
9475
9476 let total_commits = cache.map_or(0, |c| c.commits.len());
9477 let mut lines = Vec::<String>::new();
9478 if query.is_empty() {
9479 lines.push(format!(
9480 "Git commits ({}/{} correlated) | v bead list | / search | c confidence",
9481 visible.len(),
9482 total_commits
9483 ));
9484 } else {
9485 lines.push(format!(
9486 "Git commits (matches: {}/{}) | v bead list | / search",
9487 visible.len(),
9488 total_commits
9489 ));
9490 }
9491 lines.push(format!(
9492 "Min confidence: >= {:.0}%",
9493 self.history_min_confidence() * 100.0
9494 ));
9495
9496 if self.history_search_active {
9497 lines.push(format!(
9498 "Search [{}] (Tab cycles): /{}",
9499 self.history_search_mode.label(),
9500 self.history_search_query
9501 ));
9502 } else if !query.is_empty() {
9503 let matches = self.history_search_matches();
9504 let mc = matches.len();
9505 lines.push(format!(
9506 "Search [{}]: /{} ({mc} matches, n/N cycles)",
9507 self.history_search_mode.label(),
9508 self.history_search_query
9509 ));
9510 }
9511
9512 lines.push(String::new());
9513
9514 if let Some(cache) = cache {
9515 for (display_idx, &commit_idx) in visible.iter().enumerate() {
9516 let marker = if display_idx == cursor { '>' } else { ' ' };
9517 if let Some(commit) = cache.commits.get(commit_idx) {
9518 let related = self.history_git_related_beads_for_commit(&commit.sha);
9519 let beads_str = if related.len() <= 2 {
9520 related.join(",")
9521 } else {
9522 format!("{}+{}", related[..2].join(","), related.len() - 2)
9523 };
9524 let type_icon = commit_type_icon(&commit.message);
9525 let msg = truncate_str(&commit.message, 28);
9526 let ts = compact_history_duration_label(&commit.timestamp);
9527 lines.push(format!(
9528 "{marker} {type_icon} {} {:<8} {} {ts}",
9529 commit.short_sha, beads_str, msg
9530 ));
9531 }
9532 }
9533 }
9534 return lines.join("\n");
9535 }
9536
9537 let histories = self.analyzer.history(None, 0);
9538 let query = self.history_search_query.trim();
9539 let all_visible = self.visible_issue_indices();
9540 let visible = self.history_visible_issue_indices();
9541 if visible.is_empty() {
9542 if all_visible.is_empty() {
9543 return format!("(no issues match filter: {})", self.list_filter.label());
9544 }
9545 if query.is_empty() {
9546 return "(no issues available)".to_string();
9547 }
9548 return format!("(no issues match history search: /{query})");
9549 }
9550
9551 let mut lines = Vec::<String>::new();
9552 if query.is_empty() {
9553 lines.push(format!(
9554 "Bead history list ({} beads) | v toggles to git timeline | / search",
9555 visible.len()
9556 ));
9557 } else {
9558 lines.push(format!(
9559 "Bead history list (matches: {}/{}) | v toggles to git timeline | / search",
9560 visible.len(),
9561 all_visible.len()
9562 ));
9563 }
9564
9565 if self.history_search_active {
9566 lines.push(format!(
9567 "Search [{}] (Tab cycles): /{}",
9568 self.history_search_mode.label(),
9569 self.history_search_query
9570 ));
9571 } else if !query.is_empty() {
9572 let mc = visible.len();
9573 lines.push(format!(
9574 "Search [{}]: /{} ({mc} matches, n/N cycles)",
9575 self.history_search_mode.label(),
9576 self.history_search_query
9577 ));
9578 }
9579
9580 lines.push(String::new());
9581 lines.extend(
9582 visible
9583 .into_iter()
9584 .filter_map(|index| self.analyzer.issues.get(index).map(|issue| (index, issue)))
9585 .map(|(index, issue)| {
9586 let marker = if index == self.selected {
9587 "\u{25b8}"
9588 } else {
9589 " "
9590 };
9591 let event_count = histories
9592 .iter()
9593 .find(|entry| entry.id == issue.id)
9594 .map_or(0, |entry| entry.events.len());
9595 let si = status_icon(&issue.status);
9596 let ti = type_icon(&issue.issue_type);
9597 format!(
9598 "{marker} {si}{ti} {:<12} {event_count:>2}\u{25aa} {:<11}",
9599 issue.id, issue.status
9600 )
9601 }),
9602 );
9603 lines.join("\n")
9604 }
9605
9606 fn history_middle_text(&self, width: u16, height: u16) -> String {
9607 let inner_width = usize::from(width.saturating_sub(4)).max(12);
9608 let visible_rows = usize::from(height.saturating_sub(4)).max(1);
9609
9610 if matches!(self.history_view_mode, HistoryViewMode::Git) {
9611 let Some(commit) = self.selected_history_git_commit() else {
9612 return "Select a commit to view related beads.".to_string();
9613 };
9614
9615 let related = self.history_git_related_beads_for_commit(&commit.sha);
9616 if related.is_empty() {
9617 return format!("No beads correlated with {}.", commit.short_sha);
9618 }
9619
9620 let slot = self
9621 .history_related_bead_cursor
9622 .min(related.len().saturating_sub(1));
9623 let start = slot.saturating_sub(visible_rows.saturating_sub(1));
9624 let end = (start + visible_rows).min(related.len());
9625 let mut lines = vec![
9626 format!("{} related bead(s) for {}", related.len(), commit.short_sha),
9627 String::new(),
9628 ];
9629
9630 for (idx, bead_id) in related.iter().enumerate().skip(start).take(end - start) {
9631 let marker = if idx == slot && matches!(self.focus, FocusPane::Middle) {
9632 '>'
9633 } else {
9634 ' '
9635 };
9636 let issue = self.issue_by_id(bead_id);
9637 let status = issue.map_or("?", |issue| status_icon(&issue.status));
9638 let title = issue
9639 .map(|issue| truncate_str(&issue.title, inner_width.saturating_sub(18)))
9640 .unwrap_or_else(|| bead_id.clone());
9641 lines.push(format!("{marker} [{status}] {bead_id:<8} {title}"));
9642 }
9643
9644 if end < related.len() {
9645 lines.push(format!("+{} more", related.len() - end));
9646 }
9647
9648 return lines.join("\n");
9649 }
9650
9651 let Some(issue) = self.selected_issue() else {
9652 return "Select a bead to view correlated commits.".to_string();
9653 };
9654 let commits = self.history_filtered_bead_commits(&issue.id);
9655 if commits.is_empty() {
9656 return format!("No commits correlated with {}.", issue.id);
9657 }
9658
9659 let slot = self
9660 .history_bead_commit_cursor
9661 .min(commits.len().saturating_sub(1));
9662 let start = slot.saturating_sub(visible_rows.saturating_sub(1));
9663 let end = (start + visible_rows).min(commits.len());
9664 let mut lines = vec![
9665 format!("{} commit(s) for {}", commits.len(), issue.id),
9666 String::new(),
9667 ];
9668
9669 for (idx, commit) in commits.iter().enumerate().skip(start).take(end - start) {
9670 let marker = if idx == slot && matches!(self.focus, FocusPane::Middle) {
9671 '>'
9672 } else {
9673 ' '
9674 };
9675 let summary = truncate_str(&commit.message, inner_width.saturating_sub(18));
9676 lines.push(format!(
9677 "{marker} {} {:>3.0}% {}",
9678 commit.short_sha,
9679 commit.confidence * 100.0,
9680 summary
9681 ));
9682 }
9683
9684 if end < commits.len() {
9685 lines.push(format!("+{} more", commits.len() - end));
9686 }
9687
9688 lines.join("\n")
9689 }
9690
9691 fn history_timeline_text(&self, width: u16, height: u16) -> String {
9692 let Some(issue) = self.selected_issue() else {
9693 return "Select a bead to view its timeline.".to_string();
9694 };
9695 let inner_width = usize::from(width.saturating_sub(4)).max(12);
9696 let visible_rows = usize::from(height.saturating_sub(4)).max(1);
9697 let compat_history = self
9698 .history_git_cache
9699 .as_ref()
9700 .and_then(|cache| cache.histories.get(&issue.id));
9701 let filtered_commits = self.history_filtered_bead_commits(&issue.id);
9702
9703 if let Some(compat_history) = compat_history {
9704 let mut lines = vec![format!("Timeline: {}", issue.id), String::new()];
9705 lines.push(self.history_compact_timeline_text(compat_history, inner_width));
9706 if let Some(cycle) = compat_history
9707 .cycle_time
9708 .as_ref()
9709 .and_then(|cycle| cycle.create_to_close.as_deref())
9710 {
9711 lines.push(format!("Cycle: {}", compact_history_duration_label(cycle)));
9712 }
9713 if !filtered_commits.is_empty() {
9714 let avg_confidence = filtered_commits
9715 .iter()
9716 .map(|commit| commit.confidence)
9717 .sum::<f64>()
9718 / filtered_commits.len() as f64;
9719 lines.push(format!(
9720 "Commits: {} | Avg confidence: {:.0}%",
9721 filtered_commits.len(),
9722 avg_confidence * 100.0
9723 ));
9724 }
9725 lines.push(String::new());
9726
9727 let used_rows = lines.len();
9728 let max_timeline_rows = visible_rows.saturating_sub(used_rows).max(1);
9729 lines.extend(render_legacy_timeline_lines(
9730 compat_history,
9731 &filtered_commits,
9732 inner_width,
9733 max_timeline_rows,
9734 ));
9735 return lines.join("\n");
9736 }
9737
9738 let selected_history = self.analyzer.history(Some(&issue.id), 1).into_iter().next();
9739
9740 let mut entries = Vec::new();
9741 if let Some(history) = selected_history {
9742 for event in history.events {
9743 let ts = event
9744 .timestamp
9745 .map(|dt| format_compact_timestamp(Some(dt)))
9746 .unwrap_or_else(|| "n/a".to_string());
9747 let detail = truncate_str(&event.details, inner_width.saturating_sub(16));
9748 entries.push(format!("{} {ts} {}", lifecycle_icon(&event.kind), detail));
9749 }
9750 }
9751
9752 for commit in self
9753 .history_filtered_bead_commits(&issue.id)
9754 .into_iter()
9755 .take(visible_rows)
9756 {
9757 let summary = truncate_str(&commit.message, inner_width.saturating_sub(14));
9758 entries.push(format!("• {} {}", commit.short_sha, summary));
9759 }
9760
9761 if entries.is_empty() {
9762 return format!("No timeline data for {}.", issue.id);
9763 }
9764
9765 let hidden = entries.len().saturating_sub(visible_rows);
9766 let mut lines = vec![format!("Cycle view for {}", issue.id), String::new()];
9767 lines.extend(entries.into_iter().take(visible_rows));
9768 if hidden > 0 {
9769 lines.push(format!("+{hidden} more"));
9770 }
9771 lines.join("\n")
9772 }
9773
9774 fn history_compact_timeline_text(
9775 &self,
9776 history: &HistoryBeadCompat,
9777 max_width: usize,
9778 ) -> String {
9779 let mut markers = Vec::<&str>::new();
9780 let mut start_ts = None::<&str>;
9781 let mut end_ts = None::<&str>;
9782
9783 if let Some(ref event) = history.milestones.created {
9784 markers.push("○");
9785 start_ts = Some(event.timestamp.as_str());
9786 }
9787 if let Some(ref event) = history.milestones.claimed {
9788 markers.push("●");
9789 start_ts = start_ts.or(Some(event.timestamp.as_str()));
9790 }
9791
9792 let commit_count = history.commits.as_ref().map_or(0, Vec::len);
9793 if commit_count > 5 {
9794 markers.extend(["├", "├", "├", "├", "…"]);
9795 } else {
9796 for _ in 0..commit_count {
9797 markers.push("├");
9798 }
9799 }
9800
9801 if let Some(ref event) = history.milestones.closed {
9802 markers.push("✓");
9803 end_ts = Some(event.timestamp.as_str());
9804 }
9805
9806 if markers.is_empty() {
9807 return "(no timeline data)".to_string();
9808 }
9809
9810 let mut summary = Vec::<String>::new();
9811 if let Some(ref cycle) = history.cycle_time {
9812 if let Some(ref create_to_close) = cycle.create_to_close {
9813 summary.push(format!(
9814 "{} cycle",
9815 compact_history_duration_label(create_to_close)
9816 ));
9817 }
9818 }
9819 if commit_count > 0 {
9820 summary.push(if commit_count == 1 {
9821 "1 commit".to_string()
9822 } else {
9823 format!("{commit_count} commits")
9824 });
9825 }
9826
9827 let mut result = markers.join("──");
9828 if !summary.is_empty() {
9829 result.push_str(" ");
9830 result.push_str(&summary.join(", "));
9831 }
9832
9833 if let (Some(start), Some(end)) = (start_ts, end_ts) {
9834 if let (Some(start), Some(end)) = (
9835 compact_history_month_day(start),
9836 compact_history_month_day(end),
9837 ) {
9838 let date_range = format!("{start} ─ {end}");
9839 if result.chars().count() + date_range.chars().count() + 4 < max_width {
9840 result.push('\n');
9841 result.push_str(&date_range);
9842 }
9843 }
9844 }
9845
9846 result
9847 .lines()
9848 .map(|line| truncate_display(line, max_width))
9849 .collect::<Vec<_>>()
9850 .join("\n")
9851 }
9852
9853 fn compute_actionable_plan(&mut self) {
9856 let triage = self
9857 .analyzer
9858 .triage(crate::analysis::triage::TriageOptions::default());
9859 let plan = self.analyzer.plan(&triage.score_by_id);
9860 self.actionable_track_cursor = 0;
9861 self.actionable_item_cursor = 0;
9862 self.actionable_plan = Some(plan);
9863 }
9864
9865 fn move_actionable_cursor(&mut self, delta: isize) {
9866 let Some(plan) = self.actionable_plan.as_ref() else {
9867 return;
9868 };
9869
9870 let previous_track = self.actionable_track_cursor;
9871 let previous_item = self.actionable_item_cursor;
9872
9873 if matches!(self.focus, FocusPane::List) {
9874 let max = plan.tracks.len().saturating_sub(1);
9876 let new_pos = (self.actionable_track_cursor as isize + delta).clamp(0, max as isize);
9877 self.actionable_track_cursor = new_pos as usize;
9878 self.actionable_item_cursor = 0;
9879 } else {
9880 if let Some(track) = plan.tracks.get(self.actionable_track_cursor) {
9882 let max = track.items.len().saturating_sub(1);
9883 let new_pos = (self.actionable_item_cursor as isize + delta).clamp(0, max as isize);
9884 self.actionable_item_cursor = new_pos as usize;
9885 }
9886 }
9887
9888 if self.actionable_track_cursor != previous_track
9889 || self.actionable_item_cursor != previous_item
9890 {
9891 self.detail_scroll_offset = 0;
9892 }
9893 }
9894
9895 fn actionable_list_text(&self) -> String {
9896 let Some(plan) = self.actionable_plan.as_ref() else {
9897 return "(no execution plan computed)".to_string();
9898 };
9899
9900 if plan.tracks.is_empty() {
9901 return "⚡ ACTIONABLE ITEMS\n\n✓ No actionable items. All tasks are either blocked or completed."
9902 .to_string();
9903 }
9904
9905 let mut lines = Vec::new();
9906 lines.push(format!(
9907 "⚡ ACTIONABLE ITEMS | {} items in {} tracks",
9908 plan.summary.actionable_count,
9909 plan.tracks.len()
9910 ));
9911 if let (Some(highest), Some(reason)) = (
9912 plan.summary.highest_impact.as_deref(),
9913 plan.summary.impact_reason.as_deref(),
9914 ) {
9915 lines.push(format!("RECOMMENDED: Start with {highest} -> {reason}"));
9916 }
9917 lines.push(String::new());
9918
9919 for (track_idx, track) in plan.tracks.iter().enumerate() {
9920 let track_marker = if track_idx == self.actionable_track_cursor
9921 && matches!(self.focus, FocusPane::List)
9922 {
9923 "▸"
9924 } else {
9925 " "
9926 };
9927 let track_label = track.id.strip_prefix("track-").unwrap_or(&track.id);
9928 lines.push(format!(
9929 "{track_marker} TRACK {track_label} | {}",
9930 track.reason
9931 ));
9932 lines.push(format!(
9933 " {} item{}",
9934 track.items.len(),
9935 if track.items.len() == 1 { "" } else { "s" }
9936 ));
9937 for (item_idx, item) in track.items.iter().enumerate() {
9938 let item_marker = if track_idx == self.actionable_track_cursor
9939 && item_idx == self.actionable_item_cursor
9940 && matches!(self.focus, FocusPane::Detail)
9941 {
9942 " ▸"
9943 } else {
9944 " "
9945 };
9946 let tree = if item_idx + 1 < track.items.len() {
9947 "├─"
9948 } else {
9949 "└─"
9950 };
9951 let title = truncate_str(&item.title, 42);
9952 let unblocks_str = if item.unblocks.is_empty() {
9953 String::new()
9954 } else if item.unblocks.len() <= 2 {
9955 format!(" \u{2192} {}", item.unblocks.join(", "))
9956 } else {
9957 format!(
9958 " \u{2192} {}, +{}",
9959 item.unblocks[..2].join(", "),
9960 item.unblocks.len() - 2
9961 )
9962 };
9963 lines.push(format!(
9964 "{item_marker}{tree} P{} {:<12} {:>5.2} {}{unblocks_str}",
9965 item.priority.clamp(0, 4),
9966 item.id,
9967 item.score,
9968 title
9969 ));
9970 }
9971 lines.push(String::new());
9972 }
9973
9974 let width = usize::from(cached_view_width());
9976 if width >= 120 && plan.tracks.len() >= 2 {
9977 lines.push(String::new());
9978 lines.push("═══ Parallel Track Overview ═══".to_string());
9979 let col_width = (width.saturating_sub(4)) / plan.tracks.len().min(4);
9980 for chunk in plan.tracks.chunks(4) {
9981 let headers = chunk.iter().fold(String::new(), |mut acc, track| {
9983 let label = track.id.strip_prefix("track-").unwrap_or(&track.id);
9984 let title = format!("Track {label} ({})", track.items.len());
9985 let _ = write!(acc, "{title:<w$}", w = col_width);
9986 acc
9987 });
9988 lines.push(headers);
9989 let max_items = chunk
9991 .iter()
9992 .map(|t| t.items.len().min(5))
9993 .max()
9994 .unwrap_or(0);
9995 for row in 0..max_items {
9996 let row_text: String = chunk
9997 .iter()
9998 .map(|t| {
9999 if let Some(item) = t.items.get(row) {
10000 format!(
10001 "{:<w$}",
10002 format!(" {} {:.2}", truncate_str(&item.id, 10), item.score),
10003 w = col_width
10004 )
10005 } else {
10006 " ".repeat(col_width)
10007 }
10008 })
10009 .collect();
10010 lines.push(row_text);
10011 }
10012 lines.push(String::new());
10013 }
10014 }
10015
10016 lines.join("\n").trim_end().to_string()
10017 }
10018
10019 fn actionable_detail_text(&self) -> String {
10020 let Some(plan) = self.actionable_plan.as_ref() else {
10021 return "(no plan)".to_string();
10022 };
10023
10024 let Some(track) = plan.tracks.get(self.actionable_track_cursor) else {
10025 return "(no track selected)".to_string();
10026 };
10027
10028 let track_label = track.id.strip_prefix("track-").unwrap_or(&track.id);
10029
10030 let mut lines = Vec::new();
10031 lines.push(format!("TRACK {track_label}"));
10032 lines.push(track.reason.clone());
10033 lines.push(format!(
10034 "{} actionable item{}",
10035 track.items.len(),
10036 if track.items.len() == 1 { "" } else { "s" }
10037 ));
10038 lines.push(String::new());
10039
10040 for (idx, item) in track.items.iter().enumerate() {
10041 let marker = if idx == self.actionable_item_cursor {
10042 "▸"
10043 } else {
10044 " "
10045 };
10046 lines.push(format!("{marker} {} score {:.3}", item.id, item.score));
10047 lines.push(format!(" {}", item.title));
10048 if !item.unblocks.is_empty() {
10049 lines.push(format!(" Unblocks: {}", item.unblocks.join(", ")));
10050 }
10051 lines.push(format!(" Claim: {}", item.claim_command));
10052 lines.push(String::new());
10053 }
10054
10055 if let (Some(highest), Some(reason)) = (
10056 plan.summary.highest_impact.as_deref(),
10057 plan.summary.impact_reason.as_deref(),
10058 ) {
10059 lines.push(format!("Highest impact: {highest}"));
10060 lines.push(format!("Impact detail: {reason}"));
10061 }
10062
10063 lines.join("\n").trim_end().to_string()
10064 }
10065
10066 fn compute_attention(&mut self) {
10071 let result = crate::analysis::label_intel::compute_label_attention(
10072 &self.analyzer.issues,
10073 &self.analyzer.metrics,
10074 0, );
10076 self.attention_result = Some(result);
10077 self.attention_cursor = 0;
10078 }
10079
10080 fn copy_selected_issue_id(&mut self) {
10081 if let Some(issue) = self.selected_issue() {
10082 let id = issue.id.clone();
10083 if copy_text_to_clipboard(&id) {
10084 self.status_msg = format!("Copied {id} to clipboard");
10085 } else {
10086 self.status_msg = "Clipboard not available".into();
10087 }
10088 }
10089 }
10090
10091 fn export_selected_issue_markdown(&mut self) {
10092 let Some(issue) = self.selected_issue() else {
10093 return;
10094 };
10095
10096 let mut md = String::new();
10097 md.push_str(&format!("# {} — {}\n\n", issue.id, issue.title));
10098 md.push_str(&format!(
10099 "**Status:** {} | **Priority:** p{} | **Type:** {}\n\n",
10100 issue.status, issue.priority, issue.issue_type
10101 ));
10102 if !issue.assignee.is_empty() {
10103 md.push_str(&format!("**Assignee:** {}\n\n", issue.assignee));
10104 }
10105 if !issue.description.is_empty() {
10106 md.push_str(&format!("## Description\n\n{}\n\n", issue.description));
10107 }
10108 if !issue.notes.is_empty() {
10109 md.push_str(&format!("## Notes\n\n{}\n\n", issue.notes));
10110 }
10111 if !issue.labels.is_empty() {
10112 md.push_str(&format!("**Labels:** {}\n", issue.labels.join(", ")));
10113 }
10114
10115 let path = std::env::temp_dir().join(format!("{}.md", issue.id));
10116 match std::fs::write(&path, &md) {
10117 Ok(()) => {
10118 self.status_msg = format!("Exported to {}", path.display());
10119 }
10120 Err(err) => {
10121 self.status_msg = format!("Export failed: {err}");
10122 }
10123 }
10124 }
10125
10126 fn open_selected_in_editor(&mut self) {
10127 let Some(issue) = self.selected_issue() else {
10128 return;
10129 };
10130
10131 let yaml = format!(
10132 "id: {}\ntitle: {}\nstatus: {}\npriority: {}\ntype: {}\nassignee: {}\nlabels: [{}]\n\ndescription: |\n {}\n\nnotes: |\n {}\n",
10133 issue.id,
10134 issue.title,
10135 issue.status,
10136 issue.priority,
10137 issue.issue_type,
10138 issue.assignee,
10139 issue.labels.join(", "),
10140 issue.description.replace('\n', "\n "),
10141 issue.notes.replace('\n', "\n "),
10142 );
10143
10144 let path = std::env::temp_dir().join(format!("{}.yaml", issue.id));
10145 if std::fs::write(&path, &yaml).is_err() {
10146 self.status_msg = "Failed to write temp file".into();
10147 return;
10148 }
10149
10150 let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vim".to_string());
10151 if run_command(&editor, &[&path.to_string_lossy()]) {
10152 self.status_msg = format!("Opened in {editor}");
10153 } else {
10154 self.status_msg = format!("Failed to open {editor}");
10155 }
10156 }
10157
10158 fn refresh_from_disk(&mut self) {
10159 let repo_path = self.repo_root.as_deref();
10160 let issues = match loader::load_issues(repo_path) {
10161 Ok(issues) => issues,
10162 Err(_) => return, };
10164
10165 let selected_id = self
10167 .analyzer
10168 .issues
10169 .get(self.selected)
10170 .map(|i| i.id.clone());
10171
10172 let use_two_phase =
10173 issues.len() > crate::analysis::graph::AnalysisConfig::background_threshold();
10174 if use_two_phase {
10175 self.analyzer = Analyzer::new_fast(issues);
10176 #[cfg(not(test))]
10177 {
10178 self.slow_metrics_rx = Some(self.analyzer.spawn_slow_computation());
10179 }
10180 self.slow_metrics_pending = true;
10181 } else {
10182 self.analyzer = Analyzer::new(issues);
10183 self.slow_metrics_pending = false;
10184 #[cfg(not(test))]
10185 {
10186 self.slow_metrics_rx = None;
10187 }
10188 }
10189
10190 if let Some(ref id) = selected_id {
10192 if let Some(pos) = self.analyzer.issues.iter().position(|i| i.id == *id) {
10193 self.selected = pos;
10194 } else {
10195 self.selected = 0;
10196 }
10197 } else {
10198 self.selected = 0;
10199 }
10200
10201 self.actionable_plan = None;
10203 self.attention_result = None;
10204
10205 match self.mode {
10207 ViewMode::Actionable => self.compute_actionable_plan(),
10208 ViewMode::Attention => self.compute_attention(),
10209 _ => {}
10210 }
10211 self.rebuild_tree_if_active();
10214 }
10215
10216 fn move_attention_cursor(&mut self, delta: i32) {
10217 let count = self.attention_result.as_ref().map_or(0, |r| r.labels.len());
10218 if count == 0 {
10219 return;
10220 }
10221 let cur = self.attention_cursor as i32 + delta;
10222 self.attention_cursor = cur.clamp(0, count as i32 - 1) as usize;
10223 }
10224
10225 fn attention_list_text(&self) -> String {
10226 let Some(result) = self.attention_result.as_ref() else {
10227 return "(computing attention scores…)".to_string();
10228 };
10229 if result.labels.is_empty() {
10230 return "(no labels found)".to_string();
10231 }
10232
10233 let mut lines = Vec::new();
10234 lines.push(format!(
10235 "{:>4} {:<20} {:>6} {}",
10236 "Rank", "Label", "Score", "Reason"
10237 ));
10238 lines.push(format!("{}", "─".repeat(72)));
10239
10240 for (idx, label) in result.labels.iter().enumerate() {
10241 let marker = if idx == self.attention_cursor {
10242 "▸"
10243 } else {
10244 " "
10245 };
10246 lines.push(format!(
10247 "{marker}{:>3} {:<20} {:>5.1} {}",
10248 label.rank,
10249 truncate_str(&label.label, 20),
10250 label.attention_score,
10251 truncate_str(&label.reason, 30),
10252 ));
10253 }
10254
10255 lines.join("\n")
10256 }
10257
10258 fn attention_detail_text(&self) -> String {
10259 let Some(result) = self.attention_result.as_ref() else {
10260 return "(no attention data)".to_string();
10261 };
10262 let Some(label) = result.labels.get(self.attention_cursor) else {
10263 return "(no label selected)".to_string();
10264 };
10265
10266 let mut lines = Vec::new();
10267 lines.push(format!("Label: {}", label.label));
10268 lines.push(format!("Rank: #{}", label.rank));
10269 lines.push(format!("Attention Score: {:.3}", label.attention_score));
10270 lines.push(format!("Normalized: {:.3}", label.normalized_score));
10271 lines.push(String::new());
10272
10273 lines.push("Breakdown:".to_string());
10274 lines.push(format!(" Open: {}", label.open_count));
10275 lines.push(format!(" Blocked: {}", label.blocked_count));
10276 lines.push(format!(" Stale: {}", label.stale_count));
10277 lines.push(String::new());
10278
10279 lines.push("Factors:".to_string());
10280 lines.push(format!(" PageRank sum: {:.4}", label.pagerank_sum));
10281 lines.push(format!(" Staleness factor: {:.2}", label.staleness_factor));
10282 lines.push(format!(" Block impact: {:.1}", label.block_impact));
10283 lines.push(format!(" Velocity factor: {:.2}", label.velocity_factor));
10284 lines.push(String::new());
10285
10286 lines.push(format!("Reason: {}", label.reason));
10287
10288 let issues_with_label: Vec<&str> = self
10290 .analyzer
10291 .issues
10292 .iter()
10293 .filter(|i| {
10294 i.is_open_like()
10295 && i.labels
10296 .iter()
10297 .any(|candidate| candidate.eq_ignore_ascii_case(&label.label))
10298 })
10299 .map(|i| i.id.as_str())
10300 .collect();
10301 if !issues_with_label.is_empty() {
10302 lines.push(String::new());
10303 lines.push(format!("Open issues ({}):", issues_with_label.len()));
10304 for id in &issues_with_label {
10305 lines.push(format!(" {id}"));
10306 }
10307 }
10308
10309 lines.join("\n")
10310 }
10311
10312 fn build_tree_flat_nodes(&mut self) {
10317 let issues = &self.analyzer.issues;
10318
10319 let filter_active = self.has_active_filter();
10323 let passes: Vec<bool> = issues
10324 .iter()
10325 .map(|issue| self.issue_matches_filter(issue))
10326 .collect();
10327
10328 let id_to_index: std::collections::HashMap<&str, usize> = issues
10330 .iter()
10331 .enumerate()
10332 .map(|(i, issue)| (issue.id.as_str(), i))
10333 .collect();
10334
10335 let mut children_of: std::collections::HashMap<usize, Vec<usize>> =
10339 std::collections::HashMap::new();
10340 let mut has_parent: std::collections::HashSet<usize> = std::collections::HashSet::new();
10341
10342 for (i, issue) in issues.iter().enumerate() {
10343 for dep in &issue.dependencies {
10344 if dep.is_parent_child() && !dep.depends_on_id.trim().is_empty() {
10345 if let Some(&parent_idx) = id_to_index.get(dep.depends_on_id.as_str()) {
10346 children_of.entry(parent_idx).or_default().push(i);
10347 has_parent.insert(i);
10348 }
10349 }
10350 }
10351 }
10352
10353 for children in children_of.values_mut() {
10355 children.sort_by(|&a, &b| issues[a].id.cmp(&issues[b].id));
10356 children.dedup();
10357 }
10358
10359 let mut roots: Vec<usize> = (0..issues.len())
10363 .filter(|i| !has_parent.contains(i))
10364 .collect();
10365
10366 if roots.len() == issues.len() && !issues.is_empty() {
10367 let graph = &self.analyzer.graph;
10370 let mut blocking_children_of: std::collections::HashMap<usize, Vec<usize>> =
10371 std::collections::HashMap::new();
10372 let mut has_blocker: std::collections::HashSet<usize> =
10373 std::collections::HashSet::new();
10374
10375 for (i, issue) in issues.iter().enumerate() {
10376 let blockers = graph.blockers(&issue.id);
10377 if !blockers.is_empty() {
10378 has_blocker.insert(i);
10379 for blocker_id in &blockers {
10380 if let Some(&blocker_idx) = id_to_index.get(blocker_id.as_str()) {
10381 blocking_children_of.entry(blocker_idx).or_default().push(i);
10382 }
10383 }
10384 }
10385 }
10386 for children in blocking_children_of.values_mut() {
10387 children.sort_by(|&a, &b| issues[a].id.cmp(&issues[b].id));
10388 children.dedup();
10389 }
10390
10391 roots = (0..issues.len())
10392 .filter(|i| !has_blocker.contains(i))
10393 .collect();
10394
10395 children_of = blocking_children_of;
10396 }
10397
10398 roots.sort_by(|&a, &b| issues[a].id.cmp(&issues[b].id));
10399
10400 let visible: Option<std::collections::HashSet<usize>> = if filter_active {
10404 let mut vis: std::collections::HashSet<usize> = std::collections::HashSet::new();
10405 fn mark_visible(
10407 idx: usize,
10408 children_of: &std::collections::HashMap<usize, Vec<usize>>,
10409 passes: &[bool],
10410 vis: &mut std::collections::HashSet<usize>,
10411 visited: &mut std::collections::HashSet<usize>,
10412 ) -> bool {
10413 if !visited.insert(idx) {
10414 return vis.contains(&idx);
10415 }
10416 let mut dominated = passes[idx];
10417 if let Some(children) = children_of.get(&idx) {
10418 for &child in children {
10419 if mark_visible(child, children_of, passes, vis, visited) {
10420 dominated = true;
10421 }
10422 }
10423 }
10424 if dominated {
10425 vis.insert(idx);
10426 }
10427 dominated
10428 }
10429 let mut visited_mark: std::collections::HashSet<usize> =
10430 std::collections::HashSet::new();
10431 for &root in &roots {
10432 mark_visible(root, &children_of, &passes, &mut vis, &mut visited_mark);
10433 }
10434 Some(vis)
10435 } else {
10436 None
10437 };
10438
10439 if let Some(ref vis) = visible {
10441 roots.retain(|idx| vis.contains(idx));
10442 }
10443
10444 let issue_ids: Vec<String> = issues.iter().map(|i| i.id.clone()).collect();
10446
10447 let mut flat_nodes = Vec::new();
10449 let mut stack: Vec<(usize, usize, bool, Vec<bool>)> = Vec::new();
10451
10452 for (ri, &root_idx) in roots.iter().enumerate().rev() {
10453 let is_last = ri + 1 == roots.len();
10454 stack.push((root_idx, 0, is_last, Vec::new()));
10455 }
10456
10457 let mut visited: std::collections::HashSet<usize> = std::collections::HashSet::new();
10458
10459 while let Some((issue_idx, depth, is_last, ancestry)) = stack.pop() {
10460 if !visited.insert(issue_idx) {
10461 continue; }
10463
10464 let issue_id = &issue_ids[issue_idx];
10465 let children: Vec<usize> = children_of
10466 .get(&issue_idx)
10467 .map(|c| {
10468 c.iter()
10469 .copied()
10470 .filter(|idx| {
10471 !visited.contains(idx)
10472 && visible.as_ref().map_or(true, |v| v.contains(idx))
10473 })
10474 .collect()
10475 })
10476 .unwrap_or_default();
10477
10478 let is_collapsed = self.tree_collapsed.contains(issue_id);
10479 let has_children = !children.is_empty();
10480
10481 flat_nodes.push(TreeFlatNode {
10482 issue_index: issue_idx,
10483 depth,
10484 has_children,
10485 is_collapsed,
10486 is_last_sibling: is_last,
10487 ancestry_last: ancestry.clone(),
10488 });
10489
10490 if !is_collapsed {
10491 for (ci, &child_idx) in children.iter().enumerate().rev() {
10492 let child_is_last = ci + 1 == children.len();
10493 let mut child_ancestry = ancestry.clone();
10494 child_ancestry.push(is_last);
10495 stack.push((child_idx, depth + 1, child_is_last, child_ancestry));
10496 }
10497 }
10498 }
10499
10500 self.tree_flat_nodes = flat_nodes;
10501 }
10502
10503 fn toggle_tree_mode(&mut self) {
10504 if matches!(self.mode, ViewMode::Tree) {
10505 self.mode = ViewMode::Main;
10506 } else {
10507 self.mode = ViewMode::Tree;
10508 self.tree_cursor = 0;
10509 self.build_tree_flat_nodes();
10510 }
10511 }
10512
10513 fn tree_toggle_collapse(&mut self) {
10514 if let Some(node) = self.tree_flat_nodes.get(self.tree_cursor) {
10515 if node.has_children {
10516 let issue_id = self.analyzer.issues[node.issue_index].id.clone();
10517 if self.tree_collapsed.contains(&issue_id) {
10518 self.tree_collapsed.remove(&issue_id);
10519 } else {
10520 self.tree_collapsed.insert(issue_id);
10521 }
10522 self.build_tree_flat_nodes();
10523 if self.tree_cursor >= self.tree_flat_nodes.len() {
10525 self.tree_cursor = self.tree_flat_nodes.len().saturating_sub(1);
10526 }
10527 }
10528 }
10529 }
10530
10531 fn tree_expand_current(&mut self) {
10533 if let Some(node) = self.tree_flat_nodes.get(self.tree_cursor) {
10534 if node.has_children {
10535 let issue_id = self.analyzer.issues[node.issue_index].id.clone();
10536 if self.tree_collapsed.remove(&issue_id) {
10537 self.build_tree_flat_nodes();
10538 }
10539 }
10540 }
10541 }
10542
10543 fn tree_collapse_current(&mut self) {
10556 let Some(node) = self.tree_flat_nodes.get(self.tree_cursor) else {
10557 return;
10558 };
10559 let cursor_has_children = node.has_children;
10560 let cursor_issue_index = node.issue_index;
10561 let cursor_depth = node.depth;
10562 let cursor_issue_id = self.analyzer.issues[cursor_issue_index].id.clone();
10563
10564 if cursor_has_children && !self.tree_collapsed.contains(&cursor_issue_id) {
10566 if self.tree_collapsed.insert(cursor_issue_id) {
10567 self.build_tree_flat_nodes();
10568 if self.tree_cursor >= self.tree_flat_nodes.len() {
10569 self.tree_cursor = self.tree_flat_nodes.len().saturating_sub(1);
10570 }
10571 }
10572 return;
10573 }
10574
10575 let mut ancestor_cursor: Option<usize> = None;
10581 let mut search_depth = cursor_depth;
10582 let mut i = self.tree_cursor;
10583 while i > 0 {
10584 i -= 1;
10585 let n = &self.tree_flat_nodes[i];
10586 if n.depth < search_depth {
10587 if n.has_children {
10588 ancestor_cursor = Some(i);
10589 break;
10590 }
10591 search_depth = n.depth;
10594 }
10595 }
10596
10597 let Some(ancestor_idx) = ancestor_cursor else {
10598 return;
10600 };
10601
10602 let ancestor_issue_index = self.tree_flat_nodes[ancestor_idx].issue_index;
10603 let ancestor_issue_id = self.analyzer.issues[ancestor_issue_index].id.clone();
10604 let inserted = self.tree_collapsed.insert(ancestor_issue_id.clone());
10605 if inserted {
10606 self.build_tree_flat_nodes();
10607 }
10608 if let Some(new_cursor) = self
10611 .tree_flat_nodes
10612 .iter()
10613 .position(|n| self.analyzer.issues[n.issue_index].id == ancestor_issue_id)
10614 {
10615 self.tree_cursor = new_cursor;
10616 } else if self.tree_cursor >= self.tree_flat_nodes.len() {
10617 self.tree_cursor = self.tree_flat_nodes.len().saturating_sub(1);
10618 }
10619 }
10620
10621 fn tree_expand_all(&mut self) {
10623 if !self.tree_collapsed.is_empty() {
10624 self.tree_collapsed.clear();
10625 self.build_tree_flat_nodes();
10626 }
10627 }
10628
10629 fn tree_collapse_all(&mut self) {
10634 self.tree_collapsed.clear();
10635 self.build_tree_flat_nodes();
10636 let ids: Vec<String> = self
10637 .tree_flat_nodes
10638 .iter()
10639 .filter(|n| n.has_children)
10640 .map(|n| self.analyzer.issues[n.issue_index].id.clone())
10641 .collect();
10642 for id in ids {
10643 self.tree_collapsed.insert(id);
10644 }
10645 self.build_tree_flat_nodes();
10646 if self.tree_cursor >= self.tree_flat_nodes.len() {
10647 self.tree_cursor = self.tree_flat_nodes.len().saturating_sub(1);
10648 }
10649 }
10650
10651 fn tree_recenter_cursor(&self) {
10653 let header_lines = if self.tree_search_active || !self.tree_search_query.is_empty() {
10656 3
10657 } else {
10658 2
10659 };
10660 let cursor_line = self.tree_cursor + header_lines;
10661 let half = self.list_page_step() / 2;
10662 self.list_scroll_offset
10663 .set(cursor_line.saturating_sub(half));
10664 }
10665
10666 fn vim_jump_to_top(&mut self) {
10669 if self.tree_shortcut_focus() {
10670 self.tree_cursor = 0;
10671 } else if self.board_shortcut_focus() {
10672 self.select_edge_in_current_board_lane(false);
10673 } else if matches!(self.mode, ViewMode::History) && self.focus == FocusPane::List {
10674 self.history_event_cursor = 0;
10675 self.history_related_bead_cursor = 0;
10676 self.history_bead_commit_cursor = 0;
10677 } else if self.focus == FocusPane::List {
10678 self.select_first_visible();
10679 }
10680 }
10681
10682 fn start_tree_search(&mut self) {
10685 if !matches!(self.mode, ViewMode::Tree) || self.focus != FocusPane::List {
10686 return;
10687 }
10688 self.tree_search_active = true;
10689 self.tree_search_query.clear();
10690 self.tree_search_match_cursor = 0;
10691 }
10692
10693 fn finish_tree_search(&mut self) {
10694 self.tree_search_active = false;
10695 }
10696
10697 fn cancel_tree_search(&mut self) {
10698 self.tree_search_active = false;
10699 self.tree_search_query.clear();
10700 self.tree_search_match_cursor = 0;
10701 }
10702
10703 fn tree_search_matches(&self) -> Vec<usize> {
10704 let query = self.tree_search_query.trim().to_ascii_lowercase();
10705 if query.is_empty() {
10706 return Vec::new();
10707 }
10708 self.tree_flat_nodes
10709 .iter()
10710 .enumerate()
10711 .filter(|(_, node)| {
10712 let issue = &self.analyzer.issues[node.issue_index];
10713 issue.id.to_ascii_lowercase().contains(&query)
10714 || issue.title.to_ascii_lowercase().contains(&query)
10715 || issue.status.to_ascii_lowercase().contains(&query)
10716 })
10717 .map(|(i, _)| i)
10718 .collect()
10719 }
10720
10721 fn select_current_tree_search_match(&mut self) {
10722 let matches = self.tree_search_matches();
10723 if matches.is_empty() {
10724 return;
10725 }
10726 self.tree_search_match_cursor = self
10727 .tree_search_match_cursor
10728 .min(matches.len().saturating_sub(1));
10729 self.tree_cursor = matches[self.tree_search_match_cursor];
10730 }
10731
10732 fn move_tree_search_match_relative(&mut self, delta: isize) {
10733 let matches = self.tree_search_matches();
10734 if matches.is_empty() || delta == 0 {
10735 return;
10736 }
10737 let len = matches.len();
10738 let current = self.tree_search_match_cursor.min(len.saturating_sub(1));
10739 let step = delta.unsigned_abs() % len;
10740 let next = if delta > 0 {
10741 (current + step) % len
10742 } else {
10743 (current + len - step) % len
10744 };
10745 self.tree_search_match_cursor = next;
10746 self.tree_cursor = matches[next];
10747 }
10748
10749 fn tree_list_text(&self) -> String {
10750 self.tree_list_render_text(80).to_plain_text()
10751 }
10752
10753 fn tree_list_render_text(&self, width: u16) -> RichText {
10754 if self.tree_flat_nodes.is_empty() {
10755 return RichText::raw("(no dependency tree — all issues are independent)");
10756 }
10757
10758 let line_width = usize::from(width.saturating_sub(2)).max(24);
10759 let mut lines = Vec::new();
10760
10761 lines.push(panel_header(
10762 "Dependency tree",
10763 Some(&format!(
10764 "{} nodes | Enter/za toggle | zo/zc open/close | zR/zM all | T/Esc back",
10765 self.tree_flat_nodes.len()
10766 )),
10767 ));
10768 let tree_search_match_indices = self.tree_search_matches();
10770 let tree_search_hits: BTreeMap<usize, usize> = tree_search_match_indices
10771 .iter()
10772 .enumerate()
10773 .map(|(slot, &idx)| (idx, slot + 1))
10774 .collect();
10775
10776 if self.tree_search_active {
10777 lines.push(RichLine::raw(format!(
10778 "Search (active): /{}",
10779 self.tree_search_query
10780 )));
10781 } else if !self.tree_search_query.is_empty() {
10782 let pos = if tree_search_match_indices.is_empty() {
10783 "none".to_string()
10784 } else {
10785 format!(
10786 "{}/{}",
10787 self.tree_search_match_cursor
10788 .min(tree_search_match_indices.len().saturating_sub(1))
10789 + 1,
10790 tree_search_match_indices.len()
10791 )
10792 };
10793 lines.push(RichLine::raw(format!(
10794 "Search: /{} (n/N cycles, matches: {})",
10795 self.tree_search_query, pos
10796 )));
10797 }
10798 lines.push(section_separator(line_width));
10799
10800 for (i, node) in self.tree_flat_nodes.iter().enumerate() {
10801 let is_selected = i == self.tree_cursor;
10802 let issue = &self.analyzer.issues[node.issue_index];
10803 let mut line = RichLine::new();
10804
10805 let marker_style = if is_selected {
10807 tokens::selected()
10808 } else {
10809 tokens::dim()
10810 };
10811 line.push_span(RichSpan::styled(
10812 if is_selected { "▸" } else { " " },
10813 marker_style,
10814 ));
10815 line.push_span(RichSpan::raw(" "));
10816
10817 let mut prefix = String::new();
10819 for &parent_was_last in &node.ancestry_last {
10820 if parent_was_last {
10821 prefix.push_str(" ");
10822 } else {
10823 prefix.push_str("│ ");
10824 }
10825 }
10826 if node.depth > 0 {
10827 if node.is_last_sibling {
10828 prefix.push_str("└── ");
10829 } else {
10830 prefix.push_str("├── ");
10831 }
10832 }
10833 if !prefix.is_empty() {
10834 line.push_span(RichSpan::styled(prefix, tokens::dim()));
10835 }
10836
10837 if node.has_children {
10839 let indicator = if node.is_collapsed { "[+] " } else { "[-] " };
10840 line.push_span(RichSpan::styled(indicator, tokens::panel_title()));
10841 } else {
10842 line.push_span(RichSpan::raw(" "));
10843 }
10844
10845 let si = status_icon(&issue.status);
10847 line.push_span(RichSpan::styled(si, tokens::status_style(&issue.status)));
10848 line.push_span(RichSpan::raw(" "));
10849
10850 line.push_span(priority_badge(issue.priority));
10852 line.push_span(RichSpan::raw(" "));
10853
10854 line.push_span(RichSpan::styled(
10856 truncate_display(&issue.id, 12),
10857 tokens::dim(),
10858 ));
10859 line.push_span(RichSpan::raw(" "));
10860
10861 let title_width = line_width.saturating_sub(30 + node.depth * 4);
10863 line.push_span(RichSpan::styled(
10864 truncate_display(&issue.title, title_width.max(8)),
10865 tokens::help_desc(),
10866 ));
10867
10868 let blocks = self
10870 .analyzer
10871 .metrics
10872 .blocks_count
10873 .get(&issue.id)
10874 .copied()
10875 .unwrap_or(0);
10876 let open_bl = self.analyzer.graph.open_blockers(&issue.id).len();
10877 let bl = blocker_indicator(open_bl, blocks);
10878 if !bl.is_empty() {
10879 line.push_span(RichSpan::raw(" "));
10880 for s in bl {
10881 line.push_span(s);
10882 }
10883 }
10884
10885 if self
10887 .analyzer
10888 .metrics
10889 .cycles
10890 .iter()
10891 .any(|c| c.contains(&issue.id))
10892 {
10893 line.push_span(RichSpan::styled(
10894 " \u{27f3}",
10895 tokens::status_style("blocked"),
10896 ));
10897 }
10898
10899 if let Some(&pos) = tree_search_hits.get(&i) {
10901 line.push_span(RichSpan::styled(
10902 format!(" hit {pos}/{}", tree_search_hits.len()),
10903 tokens::panel_title(),
10904 ));
10905 }
10906
10907 lines.push(line);
10908 }
10909
10910 RichText::from_lines(lines)
10911 }
10912
10913 fn tree_detail_text(&self) -> String {
10914 let Some(node) = self.tree_flat_nodes.get(self.tree_cursor) else {
10915 return "(no node selected)".to_string();
10916 };
10917
10918 let issue = &self.analyzer.issues[node.issue_index];
10919 let blockers = self.analyzer.graph.blockers(&issue.id);
10920 let dependents = self.analyzer.graph.dependents(&issue.id);
10921
10922 let mut lines = Vec::new();
10923 lines.push(format!("ID: {}", issue.id));
10924 lines.push(format!("Title: {}", issue.title));
10925 lines.push(format!("Status: {}", issue.status));
10926 lines.push(format!("Type: {}", issue.issue_type));
10927 lines.push(format!("Depth: {}", node.depth));
10928
10929 if !issue.labels.is_empty() {
10930 lines.push(format!("Labels: {}", issue.labels.join(", ")));
10931 }
10932
10933 if !blockers.is_empty() {
10934 lines.push(String::new());
10935 lines.push(format!("Blocked by ({}):", blockers.len()));
10936 for b in &blockers {
10937 let title = self
10938 .analyzer
10939 .issues
10940 .iter()
10941 .find(|i| i.id == *b)
10942 .map(|i| i.title.as_str())
10943 .unwrap_or("?");
10944 lines.push(format!(" {b} - {title}"));
10945 }
10946 }
10947
10948 if !dependents.is_empty() {
10949 lines.push(String::new());
10950 lines.push(format!("Dependents ({}):", dependents.len()));
10951 for d in &dependents {
10952 let title = self
10953 .analyzer
10954 .issues
10955 .iter()
10956 .find(|i| i.id == *d)
10957 .map(|i| i.title.as_str())
10958 .unwrap_or("?");
10959 lines.push(format!(" {d} - {title}"));
10960 }
10961 }
10962
10963 if !issue.description.is_empty() {
10964 lines.push(String::new());
10965 lines.push("Description:".to_string());
10966 lines.push(issue.description.clone());
10967 }
10968
10969 lines.join("\n")
10970 }
10971
10972 fn toggle_label_dashboard(&mut self) {
10977 if matches!(self.mode, ViewMode::LabelDashboard) {
10978 self.mode = ViewMode::Main;
10979 } else {
10980 self.mode = ViewMode::LabelDashboard;
10981 self.label_dashboard_cursor = 0;
10982 self.compute_label_dashboard();
10983 }
10984 }
10985
10986 fn compute_label_dashboard(&mut self) {
10987 use crate::analysis::label_intel::compute_all_label_health;
10988 let metrics = self.analyzer.graph.compute_metrics();
10989 let result =
10990 compute_all_label_health(&self.analyzer.issues, &self.analyzer.graph, &metrics);
10991 self.label_dashboard = Some(result);
10992 }
10993
10994 fn label_dashboard_list_text(&self) -> String {
10995 let Some(result) = &self.label_dashboard else {
10996 return "(computing label health...)".to_string();
10997 };
10998
10999 if result.labels.is_empty() {
11000 return "(no labels found in issues)".to_string();
11001 }
11002
11003 let mut lines = Vec::new();
11004 lines.push(format!(
11005 "Label health ({} labels) | healthy:{} warn:{} critical:{}",
11006 result.total_labels, result.healthy_count, result.warning_count, result.critical_count
11007 ));
11008 lines.push(String::new());
11009
11010 for (i, label) in result.labels.iter().enumerate() {
11011 let marker = if i == self.label_dashboard_cursor {
11012 '>'
11013 } else {
11014 ' '
11015 };
11016
11017 let bar_filled = (label.health as usize).min(100) / 10;
11019 let bar: String = (0..10)
11020 .map(|j| if j < bar_filled { '#' } else { '.' })
11021 .collect();
11022
11023 let level_marker = match label.health_level.as_str() {
11024 "critical" => "!!",
11025 "warning" => "! ",
11026 _ => " ",
11027 };
11028
11029 let velocity = label.velocity.velocity_score;
11031 let spark = if velocity > 70 {
11032 "\u{2593}\u{2593}\u{2593}" } else if velocity > 30 {
11034 "\u{2592}\u{2592}\u{2591}" } else if velocity > 0 {
11036 "\u{2591}\u{2591}\u{2591}" } else {
11038 "\u{2581}\u{2581}\u{2581}" };
11040
11041 let stale_tag = if label.freshness.stale_count > 0 {
11042 " STALE"
11043 } else {
11044 ""
11045 };
11046
11047 lines.push(format!(
11048 "{marker} {level_marker} [{bar}] {:>3} {spark} {:<14} ({} open, {} blk){stale_tag}",
11049 label.health,
11050 truncate_str(&label.label, 14),
11051 label.open_count,
11052 label.blocked_count,
11053 ));
11054 }
11055
11056 lines.join("\n")
11057 }
11058
11059 fn label_dashboard_detail_text(&self) -> String {
11060 let Some(result) = &self.label_dashboard else {
11061 return "(no data)".to_string();
11062 };
11063
11064 let Some(label) = result.labels.get(self.label_dashboard_cursor) else {
11065 return "(no label selected)".to_string();
11066 };
11067
11068 let mut lines = Vec::new();
11069 lines.push(format!("Label: {}", label.label));
11070 lines.push(format!(
11071 "Health: {}/100 ({})",
11072 label.health, label.health_level
11073 ));
11074 lines.push(format!(
11075 "Issues: {} total ({} open, {} closed, {} blocked)",
11076 label.issue_count, label.open_count, label.closed_count, label.blocked_count
11077 ));
11078
11079 lines.push(String::new());
11081 lines.push(format!(
11082 "Velocity (score: {}/100):",
11083 label.velocity.velocity_score
11084 ));
11085 lines.push(format!(
11086 " Closed 7d: {} | 30d: {}",
11087 label.velocity.closed_last_7_days, label.velocity.closed_last_30_days
11088 ));
11089 if label.velocity.avg_days_to_close > 0.0 {
11090 lines.push(format!(
11091 " Avg days to close: {:.1}",
11092 label.velocity.avg_days_to_close
11093 ));
11094 }
11095 lines.push(format!(
11096 " Trend: {} ({:+.0}%)",
11097 label.velocity.trend_direction, label.velocity.trend_percent
11098 ));
11099
11100 lines.push(String::new());
11102 lines.push(format!(
11103 "Freshness (score: {}/100):",
11104 label.freshness.freshness_score
11105 ));
11106 lines.push(format!(
11107 " Avg days since update: {:.1}",
11108 label.freshness.avg_days_since_update
11109 ));
11110 lines.push(format!(
11111 " Stale: {} (threshold: {}d)",
11112 label.freshness.stale_count, label.freshness.stale_threshold_days
11113 ));
11114
11115 lines.push(String::new());
11117 lines.push(format!("Flow (score: {}/100):", label.flow.flow_score));
11118 lines.push(format!(
11119 " Deps in: {} | out: {}",
11120 label.flow.incoming_deps, label.flow.outgoing_deps
11121 ));
11122 lines.push(format!(
11123 " Blocked by external: {} | Blocking external: {}",
11124 label.flow.blocked_by_external, label.flow.blocking_external
11125 ));
11126
11127 lines.push(String::new());
11129 lines.push(format!(
11130 "Criticality (score: {}/100):",
11131 label.criticality.criticality_score
11132 ));
11133 lines.push(format!(
11134 " Avg PageRank: {:.4} | Avg betweenness: {:.4}",
11135 label.criticality.avg_pagerank, label.criticality.avg_betweenness
11136 ));
11137 lines.push(format!(
11138 " Critical paths: {} | Bottlenecks: {}",
11139 label.criticality.critical_path_count, label.criticality.bottleneck_count
11140 ));
11141
11142 if !label.issues.is_empty() {
11144 lines.push(String::new());
11145 lines.push(format!("Issues ({}):", label.issues.len()));
11146 for id in &label.issues {
11147 let title = self
11148 .analyzer
11149 .issues
11150 .iter()
11151 .find(|i| i.id == *id)
11152 .map(|i| i.title.as_str())
11153 .unwrap_or("?");
11154 lines.push(format!(" {id} - {title}"));
11155 }
11156 }
11157
11158 lines.join("\n")
11159 }
11160
11161 fn toggle_flow_matrix(&mut self) {
11166 if matches!(self.mode, ViewMode::FlowMatrix) {
11167 self.mode = ViewMode::Main;
11168 } else {
11169 self.mode = ViewMode::FlowMatrix;
11170 self.flow_matrix_row_cursor = 0;
11171 self.flow_matrix_col_cursor = 0;
11172 self.compute_flow_matrix();
11173 }
11174 }
11175
11176 fn compute_flow_matrix(&mut self) {
11177 use crate::analysis::label_intel::compute_cross_label_flow;
11178 let result = compute_cross_label_flow(&self.analyzer.issues);
11179 self.flow_matrix = Some(result);
11180 }
11181
11182 fn flow_matrix_list_text(&self) -> String {
11183 let Some(flow) = &self.flow_matrix else {
11184 return "(computing flow matrix...)".to_string();
11185 };
11186
11187 if flow.labels.is_empty() {
11188 return "(no labels found — flow matrix empty)".to_string();
11189 }
11190
11191 let labels = &flow.labels;
11192 let matrix = &flow.flow_matrix;
11193 let mut lines = Vec::new();
11194
11195 lines.push(format!(
11197 "Cross-label flow ({} labels, {} deps) | bottlenecks: {}",
11198 labels.len(),
11199 flow.total_cross_label_deps,
11200 if flow.bottleneck_labels.is_empty() {
11201 "none".to_string()
11202 } else {
11203 flow.bottleneck_labels.join(", ")
11204 }
11205 ));
11206 lines.push(String::new());
11207
11208 let max_label_width = labels.iter().map(|l| display_width(l)).max().unwrap_or(4);
11210 let col_w = max_label_width.max(4);
11211
11212 let row_label_w = col_w + 2;
11214 let mut header = " ".repeat(row_label_w);
11215 for (ci, label) in labels.iter().enumerate() {
11216 let marker = if ci == self.flow_matrix_col_cursor {
11217 "v"
11218 } else {
11219 " "
11220 };
11221 header.push(' ');
11222 header.push_str(marker);
11223 header.push_str(&fit_display(label, col_w));
11224 }
11225 lines.push(header);
11226
11227 let sep_len = row_label_w + labels.len() * (col_w + 2);
11229 lines.push("-".repeat(sep_len));
11230
11231 for (ri, label) in labels.iter().enumerate() {
11233 let cursor = if ri == self.flow_matrix_row_cursor {
11234 ">"
11235 } else {
11236 " "
11237 };
11238 let mut row = format!("{cursor} {}", fit_display(label, col_w));
11239 for (ci, val) in matrix[ri].iter().enumerate() {
11240 let cell = if ri == ci {
11241 " .".to_string()
11242 } else if *val == 0 {
11243 " -".to_string()
11244 } else {
11245 format!(" {val}")
11246 };
11247 let highlight = ri == self.flow_matrix_row_cursor
11248 && ci == self.flow_matrix_col_cursor
11249 && ri != ci;
11250 if highlight {
11251 row.push('[');
11252 row.push_str(&fit_display(cell.trim(), col_w - 1));
11253 row.push(']');
11254 } else {
11255 row.push(' ');
11256 row.push_str(&fit_display(cell.trim(), col_w));
11257 }
11258 }
11259 lines.push(row);
11260 }
11261
11262 lines.push(String::new());
11263 lines.push("j/k rows | h/l cols | Tab focus | ] or Esc back".to_string());
11264
11265 lines.join("\n")
11266 }
11267
11268 fn flow_matrix_detail_text(&self) -> String {
11269 let Some(flow) = &self.flow_matrix else {
11270 return "(no flow data)".to_string();
11271 };
11272
11273 if flow.labels.is_empty() {
11274 return "(no labels)".to_string();
11275 }
11276
11277 let labels = &flow.labels;
11278 let row = self
11279 .flow_matrix_row_cursor
11280 .min(labels.len().saturating_sub(1));
11281 let col = self
11282 .flow_matrix_col_cursor
11283 .min(labels.len().saturating_sub(1));
11284 let from_label = &labels[row];
11285 let to_label = &labels[col];
11286
11287 let mut lines = Vec::new();
11288
11289 if row == col {
11290 lines.push(format!("Label: {from_label}"));
11291 lines.push(String::new());
11292 let issue_ids: Vec<_> = self
11294 .analyzer
11295 .issues
11296 .iter()
11297 .filter(|i| {
11298 i.labels
11299 .iter()
11300 .any(|candidate| candidate.eq_ignore_ascii_case(from_label))
11301 })
11302 .collect();
11303 lines.push(format!("Issues with this label: {}", issue_ids.len()));
11304 for issue in &issue_ids {
11305 lines.push(format!(" {} - {}", issue.id, issue.title));
11306 }
11307 } else {
11308 let flow_val = flow.flow_matrix[row][col];
11309 lines.push(format!("{from_label} -> {to_label}: {flow_val} deps"));
11310 lines.push(String::new());
11311
11312 let matching: Vec<_> = flow
11314 .dependencies
11315 .iter()
11316 .filter(|d| d.from_label == *from_label && d.to_label == *to_label)
11317 .collect();
11318
11319 if matching.is_empty() && flow_val == 0 {
11320 lines.push("No cross-label dependencies in this direction.".to_string());
11321 } else {
11322 for dep in &matching {
11323 lines.push(format!(
11324 "{} -> {} ({} issues)",
11325 dep.from_label, dep.to_label, dep.issue_count
11326 ));
11327 for id in &dep.issue_ids {
11328 let title = self
11329 .analyzer
11330 .issues
11331 .iter()
11332 .find(|i| i.id == *id)
11333 .map(|i| i.title.as_str())
11334 .unwrap_or("?");
11335 lines.push(format!(" {id} - {title}"));
11336 }
11337 }
11338 }
11339 }
11340
11341 if !flow.bottleneck_labels.is_empty() {
11343 lines.push(String::new());
11344 lines.push(format!(
11345 "Bottleneck labels: {}",
11346 flow.bottleneck_labels.join(", ")
11347 ));
11348 }
11349
11350 lines.join("\n")
11351 }
11352
11353 fn toggle_time_travel_mode(&mut self) {
11358 if matches!(self.mode, ViewMode::TimeTravelDiff) {
11359 self.mode = ViewMode::Main;
11360 self.focus = FocusPane::List;
11361 } else {
11362 self.mode = ViewMode::TimeTravelDiff;
11363 self.focus = FocusPane::List;
11364 if self.time_travel_diff.is_none() {
11365 self.time_travel_input_active = true;
11367 self.time_travel_ref_input.clear();
11368 }
11369 }
11370 }
11371
11372 fn execute_time_travel(&mut self) {
11373 let reference = self.time_travel_ref_input.trim().to_string();
11374 if reference.is_empty() {
11375 self.status_msg = "Time-travel: empty ref, cancelled".into();
11376 self.time_travel_input_active = false;
11377 if self.time_travel_diff.is_none() {
11378 self.mode = ViewMode::Main;
11379 self.focus = FocusPane::List;
11380 }
11381 return;
11382 }
11383
11384 self.time_travel_input_active = false;
11385 self.time_travel_last_ref = Some(reference.clone());
11386
11387 match self.load_time_travel_diff(&reference) {
11389 Ok(diff) => {
11390 self.time_travel_diff = Some(diff);
11391 self.time_travel_category_cursor = 0;
11392 self.time_travel_issue_cursor = 0;
11393 self.status_msg = format!("Time-travel: loaded diff from {reference}");
11394 }
11395 Err(err) => {
11396 self.status_msg = format!("Time-travel: {err}");
11397 }
11398 }
11399 }
11400
11401 fn load_time_travel_diff(
11402 &self,
11403 reference: &str,
11404 ) -> std::result::Result<crate::analysis::diff::SnapshotDiff, String> {
11405 let path = std::path::Path::new(reference);
11407 if path.is_file() {
11408 let before = crate::loader::load_issues_from_file(path)
11409 .map_err(|e| format!("load file: {e}"))?;
11410 return Ok(crate::analysis::diff::compare_snapshots(
11411 &before,
11412 &self.analyzer.issues,
11413 ));
11414 }
11415
11416 if let Some(ref root) = self.repo_root {
11418 let rooted = root.join(reference);
11419 if rooted.is_file() {
11420 let before = crate::loader::load_issues_from_file(&rooted)
11421 .map_err(|e| format!("load file: {e}"))?;
11422 return Ok(crate::analysis::diff::compare_snapshots(
11423 &before,
11424 &self.analyzer.issues,
11425 ));
11426 }
11427 }
11428
11429 let repo_root = self
11431 .repo_root
11432 .as_deref()
11433 .unwrap_or_else(|| std::path::Path::new("."));
11434 let repo_root_str = repo_root.to_string_lossy();
11435
11436 let candidates = [".beads/issues.jsonl", ".beads/beads.jsonl"];
11438 for candidate in &candidates {
11439 let output = std::process::Command::new("git")
11440 .args([
11441 "-C",
11442 &repo_root_str,
11443 "show",
11444 &format!("{reference}:{candidate}"),
11445 ])
11446 .output()
11447 .map_err(|e| format!("git show: {e}"))?;
11448
11449 if output.status.success() {
11450 let content = String::from_utf8_lossy(&output.stdout);
11451 let before = crate::loader::parse_issues_from_text(&content)
11452 .map_err(|e| format!("parse: {e}"))?;
11453 return Ok(crate::analysis::diff::compare_snapshots(
11454 &before,
11455 &self.analyzer.issues,
11456 ));
11457 }
11458 }
11459
11460 Err(format!(
11461 "could not resolve '{reference}' as file or git ref"
11462 ))
11463 }
11464
11465 fn time_travel_categories(&self) -> Vec<(&str, usize)> {
11466 let Some(ref diff) = self.time_travel_diff else {
11467 return Vec::new();
11468 };
11469 let mut cats = Vec::new();
11470 let new_count = diff.new_issues.as_ref().map_or(0, |v| v.len());
11471 let closed_count = diff.closed_issues.as_ref().map_or(0, |v| v.len());
11472 let removed_count = diff.removed_issues.as_ref().map_or(0, |v| v.len());
11473 let reopened_count = diff.reopened_issues.as_ref().map_or(0, |v| v.len());
11474 let modified_count = diff.modified_issues.as_ref().map_or(0, |v| v.len());
11475 let new_cycles = diff.new_cycles.as_ref().map_or(0, |v| v.len());
11476 let resolved_cycles = diff.resolved_cycles.as_ref().map_or(0, |v| v.len());
11477
11478 if new_count > 0 {
11479 cats.push(("New issues", new_count));
11480 }
11481 if closed_count > 0 {
11482 cats.push(("Closed issues", closed_count));
11483 }
11484 if removed_count > 0 {
11485 cats.push(("Removed issues", removed_count));
11486 }
11487 if reopened_count > 0 {
11488 cats.push(("Reopened issues", reopened_count));
11489 }
11490 if modified_count > 0 {
11491 cats.push(("Modified issues", modified_count));
11492 }
11493 if new_cycles > 0 {
11494 cats.push(("New cycles", new_cycles));
11495 }
11496 if resolved_cycles > 0 {
11497 cats.push(("Resolved cycles", resolved_cycles));
11498 }
11499 cats
11500 }
11501
11502 fn time_travel_list_text(&self) -> String {
11503 let Some(ref diff) = self.time_travel_diff else {
11504 if self.time_travel_input_active {
11505 return format!(
11506 " Enter git ref or file path:\n > {}_",
11507 self.time_travel_ref_input
11508 );
11509 }
11510 return " No diff loaded. Press t to enter a ref.".to_string();
11511 };
11512
11513 let mut lines = Vec::new();
11514 let ref_label = self.time_travel_last_ref.as_deref().unwrap_or("unknown");
11515 lines.push(format!(" Diff from: {ref_label}"));
11516 lines.push(format!(" Summary: {} changes", diff.summary.total_changes));
11517 lines.push(String::new());
11518
11519 let cats = self.time_travel_categories();
11520 if cats.is_empty() {
11521 lines.push(" (no changes detected)".to_string());
11522 } else {
11523 for (i, (label, count)) in cats.iter().enumerate() {
11524 let marker = if i == self.time_travel_category_cursor {
11525 ">"
11526 } else {
11527 " "
11528 };
11529 lines.push(format!(" {marker} {label} ({count})"));
11530 }
11531 }
11532
11533 lines.push(String::new());
11535 lines.push(" METRIC DELTAS".to_string());
11536 let md = &diff.metric_deltas;
11537 if md.total_issues != 0 {
11538 lines.push(format!(" Total issues: {:+}", md.total_issues));
11539 }
11540 if md.open_issues != 0 {
11541 lines.push(format!(" Open issues: {:+}", md.open_issues));
11542 }
11543 if md.blocked_issues != 0 {
11544 lines.push(format!(" Blocked: {:+}", md.blocked_issues));
11545 }
11546 if md.total_edges != 0 {
11547 lines.push(format!(" Edges: {:+}", md.total_edges));
11548 }
11549 if md.cycle_count != 0 {
11550 lines.push(format!(" Cycles: {:+}", md.cycle_count));
11551 }
11552
11553 lines.join("\n")
11554 }
11555
11556 fn time_travel_detail_text(&self) -> String {
11557 let Some(ref diff) = self.time_travel_diff else {
11558 return String::new();
11559 };
11560
11561 let cats = self.time_travel_categories();
11562 if cats.is_empty() {
11563 return " No changes in this diff.".to_string();
11564 }
11565
11566 let cat_idx = self
11567 .time_travel_category_cursor
11568 .min(cats.len().saturating_sub(1));
11569 let (label, _) = cats[cat_idx];
11570
11571 let mut lines = Vec::new();
11572 lines.push(format!(" {label}"));
11573 lines.push(String::new());
11574
11575 match label {
11576 "New issues" => {
11577 if let Some(ref issues) = diff.new_issues {
11578 for (i, di) in issues.iter().enumerate() {
11579 let marker = if i == self.time_travel_issue_cursor {
11580 ">"
11581 } else {
11582 " "
11583 };
11584 lines.push(format!(" {marker} {} [{}] {}", di.id, di.status, di.title));
11585 }
11586 }
11587 }
11588 "Closed issues" => {
11589 if let Some(ref issues) = diff.closed_issues {
11590 for (i, di) in issues.iter().enumerate() {
11591 let marker = if i == self.time_travel_issue_cursor {
11592 ">"
11593 } else {
11594 " "
11595 };
11596 lines.push(format!(" {marker} {} [{}] {}", di.id, di.status, di.title));
11597 }
11598 }
11599 }
11600 "Removed issues" => {
11601 if let Some(ref issues) = diff.removed_issues {
11602 for (i, di) in issues.iter().enumerate() {
11603 let marker = if i == self.time_travel_issue_cursor {
11604 ">"
11605 } else {
11606 " "
11607 };
11608 lines.push(format!(" {marker} {} [{}] {}", di.id, di.status, di.title));
11609 }
11610 }
11611 }
11612 "Reopened issues" => {
11613 if let Some(ref issues) = diff.reopened_issues {
11614 for (i, di) in issues.iter().enumerate() {
11615 let marker = if i == self.time_travel_issue_cursor {
11616 ">"
11617 } else {
11618 " "
11619 };
11620 lines.push(format!(" {marker} {} [{}] {}", di.id, di.status, di.title));
11621 }
11622 }
11623 }
11624 "Modified issues" => {
11625 if let Some(ref issues) = diff.modified_issues {
11626 for (i, mi) in issues.iter().enumerate() {
11627 let marker = if i == self.time_travel_issue_cursor {
11628 ">"
11629 } else {
11630 " "
11631 };
11632 let field_changes: Vec<&str> =
11633 mi.changes.iter().map(|c| c.field.as_str()).collect();
11634 lines.push(format!(
11635 " {marker} {} ({} fields: {})",
11636 mi.issue_id,
11637 mi.changes.len(),
11638 field_changes.join(", ")
11639 ));
11640 }
11641 }
11642 }
11643 "New cycles" => {
11644 if let Some(ref cycles) = diff.new_cycles {
11645 for (i, cycle) in cycles.iter().enumerate() {
11646 let marker = if i == self.time_travel_issue_cursor {
11647 ">"
11648 } else {
11649 " "
11650 };
11651 lines.push(format!(" {marker} [{}]", cycle.join(" -> ")));
11652 }
11653 }
11654 }
11655 "Resolved cycles" => {
11656 if let Some(ref cycles) = diff.resolved_cycles {
11657 for (i, cycle) in cycles.iter().enumerate() {
11658 let marker = if i == self.time_travel_issue_cursor {
11659 ">"
11660 } else {
11661 " "
11662 };
11663 lines.push(format!(" {marker} [{}]", cycle.join(" -> ")));
11664 }
11665 }
11666 }
11667 _ => {}
11668 }
11669
11670 lines.join("\n")
11671 }
11672
11673 fn toggle_sprint_mode(&mut self) {
11678 if matches!(self.mode, ViewMode::Sprint) {
11679 self.mode = ViewMode::Main;
11680 } else {
11681 self.load_sprint_data();
11682 self.mode = ViewMode::Sprint;
11683 self.sprint_cursor = 0;
11684 self.sprint_issue_cursor = 0;
11685 }
11686 }
11687
11688 fn load_sprint_data(&mut self) {
11689 self.sprint_data = loader::load_sprints(self.repo_root.as_deref()).unwrap_or_default();
11690 }
11691
11692 fn sprint_visible_issues(&self) -> Vec<(usize, &Issue)> {
11693 let sprint = match self.sprint_data.get(self.sprint_cursor) {
11694 Some(s) => s,
11695 None => return Vec::new(),
11696 };
11697 sprint
11698 .bead_ids
11699 .iter()
11700 .filter_map(|bead_id| {
11701 self.analyzer
11702 .issues
11703 .iter()
11704 .enumerate()
11705 .find(|(_, issue)| issue.id == *bead_id)
11706 })
11707 .collect()
11708 }
11709
11710 fn sprint_list_text(&self) -> String {
11711 if self.sprint_data.is_empty() {
11712 return " No sprints found.\n\n \
11713 Sprints are defined in .beads/sprints.jsonl\n \
11714 Each line: {\"id\":\"sprint-1\",\"name\":\"Sprint Alpha\",\n \
11715 \"start_date\":\"...\",\"end_date\":\"...\",\"bead_ids\":[...]}"
11716 .to_string();
11717 }
11718
11719 let now = sprint_reference_now();
11720 let mut lines = Vec::new();
11721 lines.push(format!(" {} sprint(s)", self.sprint_data.len()));
11722 lines.push(String::new());
11723
11724 for (i, sprint) in self.sprint_data.iter().enumerate() {
11725 let marker = if i == self.sprint_cursor { "▸" } else { " " };
11726 let active = if sprint.is_active_at(now) {
11727 " [ACTIVE]"
11728 } else {
11729 ""
11730 };
11731 let issue_count = sprint.bead_ids.len();
11732 let dates = match (&sprint.start_date, &sprint.end_date) {
11733 (Some(start), Some(end)) => {
11734 format!("{} → {}", start.format("%Y-%m-%d"), end.format("%Y-%m-%d"))
11735 }
11736 _ => "no dates".to_string(),
11737 };
11738 lines.push(format!(
11739 " {marker} {} | {} issues | {dates}{active}",
11740 sprint.name, issue_count
11741 ));
11742
11743 let matched_issues = sprint
11745 .bead_ids
11746 .iter()
11747 .filter(|id| self.analyzer.issues.iter().any(|issue| issue.id == **id))
11748 .count();
11749 let closed = sprint
11750 .bead_ids
11751 .iter()
11752 .filter(|id| {
11753 self.analyzer
11754 .issues
11755 .iter()
11756 .any(|issue| issue.id == **id && issue.is_closed_like())
11757 })
11758 .count();
11759 if matched_issues > 0 {
11760 let pct = (closed as f64 / matched_issues as f64 * 100.0) as u32;
11761 lines.push(format!(" {closed}/{matched_issues} done ({pct}%)"));
11762 }
11763 }
11764
11765 lines.join("\n")
11766 }
11767
11768 fn sprint_detail_text(&self) -> String {
11769 let sprint = match self.sprint_data.get(self.sprint_cursor) {
11770 Some(s) => s,
11771 None => return " Select a sprint from the list.".to_string(),
11772 };
11773
11774 let now = sprint_reference_now();
11775 let mut lines = Vec::new();
11776
11777 lines.push(format!(" SPRINT: {}", sprint.name));
11779 if sprint.is_active_at(now) {
11780 lines.push(" Status: ACTIVE".to_string());
11781 } else if sprint.end_date.is_some_and(|end| end < now) {
11782 lines.push(" Status: Completed".to_string());
11783 } else {
11784 lines.push(" Status: Upcoming".to_string());
11785 }
11786
11787 if let (Some(start), Some(end)) = (&sprint.start_date, &sprint.end_date) {
11788 lines.push(format!(
11789 " Dates: {} → {}",
11790 start.format("%Y-%m-%d"),
11791 end.format("%Y-%m-%d")
11792 ));
11793 let total_days = (*end - *start).num_days();
11794 let elapsed = (now - *start).num_days().max(0).min(total_days);
11795 let remaining = total_days - elapsed;
11796 lines.push(format!(
11797 " Days: {total_days} total, {elapsed} elapsed, {remaining} remaining"
11798 ));
11799 }
11800 lines.push(String::new());
11801
11802 let visible = self.sprint_visible_issues();
11804 if visible.is_empty() {
11805 lines.push(format!(
11806 " {} bead(s) assigned, none matched loaded issues.",
11807 sprint.bead_ids.len()
11808 ));
11809 } else {
11810 let total = visible.len();
11811 let closed = visible
11812 .iter()
11813 .filter(|(_, issue)| issue.is_closed_like())
11814 .count();
11815 let open = total - closed;
11816 lines.push(format!(
11817 " Issues: {total} total, {open} open, {closed} closed"
11818 ));
11819 lines.push(String::new());
11820
11821 let mut sorted: Vec<_> = visible.clone();
11823 sorted.sort_by(|(_, a), (_, b)| {
11824 let a_closed = a.is_closed_like();
11825 let b_closed = b.is_closed_like();
11826 a_closed.cmp(&b_closed).then(a.priority.cmp(&b.priority))
11827 });
11828
11829 for (i, (_, issue)) in sorted.iter().enumerate() {
11830 let marker = if i == self.sprint_issue_cursor {
11831 "▸"
11832 } else {
11833 " "
11834 };
11835 let status_icon = if issue.is_closed_like() {
11836 "✓"
11837 } else if issue.status == "in_progress" {
11838 "●"
11839 } else if issue.status == "blocked" {
11840 "✗"
11841 } else {
11842 "○"
11843 };
11844 lines.push(format!(
11845 " {marker} {status_icon} {} [P{}] {}",
11846 issue.id, issue.priority, issue.title
11847 ));
11848 }
11849
11850 lines.push(String::new());
11852 if total > 0 {
11853 let pct = (closed as f64 / total as f64 * 100.0) as usize;
11854 let filled = pct / 5;
11855 let empty = 20 - filled;
11856 let bar = format!("[{}{}] {}%", "█".repeat(filled), "░".repeat(empty), pct);
11857 lines.push(format!(" Progress: {bar}"));
11858 }
11859 }
11860
11861 lines.push(String::new());
11863 lines.push(" ─── Sprint Actions ───".to_string());
11864 lines.push(format!(" Claim next: br update <id> --status=in_progress"));
11865 lines.push(format!(" Close issue: br close <id> --reason \"done\""));
11866 if !sprint.bead_ids.is_empty() {
11867 let first_open = self
11868 .sprint_visible_issues()
11869 .iter()
11870 .find(|(_, i)| i.is_open_like())
11871 .map(|(_, i)| i.id.clone());
11872 if let Some(next_id) = first_open {
11873 lines.push(format!(
11874 " Suggested: br update {next_id} --status=in_progress"
11875 ));
11876 }
11877 }
11878
11879 lines.join("\n")
11880 }
11881
11882 fn open_recipe_picker(&mut self) {
11887 let recipes = crate::analysis::recipe::list_recipes();
11888 let items: Vec<(String, String)> = recipes
11889 .into_iter()
11890 .map(|r| (r.name, r.description))
11891 .collect();
11892 self.modal_overlay = Some(ModalOverlay::RecipePicker { items, cursor: 0 });
11893 }
11894
11895 fn open_label_picker(&mut self) {
11896 let mut label_counts = BTreeMap::<String, usize>::new();
11897 for issue in &self.analyzer.issues {
11898 for label in &issue.labels {
11899 *label_counts.entry(label.clone()).or_insert(0) += 1;
11900 }
11901 }
11902 let items: Vec<(String, usize)> = label_counts.into_iter().collect();
11903 self.modal_overlay = Some(ModalOverlay::LabelPicker {
11904 items,
11905 cursor: 0,
11906 filter: String::new(),
11907 });
11908 }
11909
11910 fn open_repo_picker(&mut self) {
11911 let mut repos = std::collections::HashSet::<String>::new();
11912 for issue in &self.analyzer.issues {
11913 if !issue.source_repo.is_empty() {
11914 repos.insert(issue.source_repo.clone());
11915 }
11916 }
11917 let mut items: Vec<String> = repos.into_iter().collect();
11918 items.sort();
11919 if items.is_empty() {
11920 self.status_msg = "No workspace repos loaded".into();
11921 return;
11922 }
11923 self.modal_overlay = Some(ModalOverlay::RepoPicker {
11924 items,
11925 cursor: 0,
11926 filter: String::new(),
11927 });
11928 }
11929
11930 fn set_label_filter(&mut self, label: &str) {
11931 if self
11932 .modal_label_filter
11933 .as_deref()
11934 .is_some_and(|current| current.eq_ignore_ascii_case(label))
11935 {
11936 self.modal_label_filter = None;
11937 self.status_msg = "Label filter cleared".into();
11938 } else {
11939 self.modal_label_filter = Some(label.to_string());
11940 self.status_msg = format!("Filtering by label: {label}");
11941 }
11942 self.list_scroll_offset.set(0);
11943 self.ensure_selected_visible();
11944 self.sync_insights_heatmap_selection();
11945 self.focus = FocusPane::List;
11946 self.rebuild_tree_if_active();
11947 }
11948
11949 fn set_repo_filter(&mut self, repo: &str) {
11950 if self
11951 .modal_repo_filter
11952 .as_deref()
11953 .is_some_and(|current| current == repo)
11954 {
11955 self.modal_repo_filter = None;
11956 self.status_msg = "Repo filter cleared".into();
11957 } else {
11958 self.modal_repo_filter = Some(repo.to_string());
11959 self.status_msg = format!("Filtering by repo: {repo}");
11960 }
11961 self.list_scroll_offset.set(0);
11962 self.ensure_selected_visible();
11963 self.sync_insights_heatmap_selection();
11964 self.focus = FocusPane::List;
11965 self.rebuild_tree_if_active();
11966 }
11967
11968 fn detail_panel_text(&self) -> String {
11971 match self.mode {
11972 ViewMode::Board => self.board_detail_text(),
11973 ViewMode::Insights => self.insights_detail_text(),
11974 ViewMode::Graph => self.graph_detail_text(),
11975 ViewMode::History => self.history_detail_text(),
11976 ViewMode::Actionable => self.actionable_detail_text(),
11977 ViewMode::Attention => self.attention_detail_text(),
11978 ViewMode::Tree => self.tree_detail_text(),
11979 ViewMode::LabelDashboard => self.label_dashboard_detail_text(),
11980 ViewMode::FlowMatrix => self.flow_matrix_detail_text(),
11981 ViewMode::TimeTravelDiff => self.time_travel_detail_text(),
11982 ViewMode::Sprint => self.sprint_detail_text(),
11983 ViewMode::Main => self.issue_detail_text(),
11984 }
11985 }
11986
11987 fn detail_panel_render_text(&self) -> RichText {
11988 match self.mode {
11989 ViewMode::Main => self.issue_detail_render_text(),
11990 ViewMode::Insights => self.insights_detail_render_text(),
11991 ViewMode::Graph => self.graph_detail_render_text(),
11992 ViewMode::History => self.history_detail_render_text(),
11993 _ => RichText::raw(self.detail_panel_text()),
11994 }
11995 }
11996
11997 fn issue_detail_render_text(&self) -> RichText {
11998 let Some(issue) = self.selected_issue() else {
11999 return RichText::raw(self.issue_detail_text());
12000 };
12001
12002 let blockers = self.analyzer.graph.blockers(&issue.id);
12003 let open_blockers = self.analyzer.graph.open_blockers(&issue.id);
12004 let dependents = self.analyzer.graph.dependents(&issue.id);
12005 let pagerank = self
12006 .analyzer
12007 .metrics
12008 .pagerank
12009 .get(&issue.id)
12010 .copied()
12011 .unwrap_or_default();
12012 let betweenness = self
12013 .analyzer
12014 .metrics
12015 .betweenness
12016 .get(&issue.id)
12017 .copied()
12018 .unwrap_or_default();
12019 let eigenvector = self
12020 .analyzer
12021 .metrics
12022 .eigenvector
12023 .get(&issue.id)
12024 .copied()
12025 .unwrap_or_default();
12026 let k_core = self
12027 .analyzer
12028 .metrics
12029 .k_core
12030 .get(&issue.id)
12031 .copied()
12032 .unwrap_or_default();
12033 let slack = self
12034 .analyzer
12035 .metrics
12036 .slack
12037 .get(&issue.id)
12038 .copied()
12039 .unwrap_or_default();
12040 let depth = self
12041 .analyzer
12042 .metrics
12043 .critical_depth
12044 .get(&issue.id)
12045 .copied()
12046 .unwrap_or_default();
12047 let articulation = self
12048 .analyzer
12049 .metrics
12050 .articulation_points
12051 .contains(&issue.id);
12052 let pr_max = max_metric_value(&self.analyzer.metrics.pagerank);
12053 let bw_max = max_metric_value(&self.analyzer.metrics.betweenness);
12054 let ev_max = max_metric_value(&self.analyzer.metrics.eigenvector);
12055 let history = self.analyzer.history(Some(&issue.id), 1).into_iter().next();
12056 let action_state = if issue.is_closed_like() {
12057 "closed"
12058 } else if open_blockers.is_empty() {
12059 "ready"
12060 } else {
12061 "blocked"
12062 };
12063 let action_subtitle = match action_state {
12064 "closed" => "reference state",
12065 "ready" => "ready to execute",
12066 _ => "waiting on blockers",
12067 };
12068 let action_line = if issue.is_closed_like() {
12069 format!(
12070 "Action: Closed work item | Downstream still watching: {}",
12071 dependents.len()
12072 )
12073 } else if open_blockers.is_empty() {
12074 format!(
12075 "Action: Pull now | Downstream impact: {} | Critical depth: {}",
12076 dependents.len(),
12077 depth
12078 )
12079 } else {
12080 format!(
12081 "Action: Unblock first via {} | Open blockers: {} | Downstream impact: {}",
12082 join_display_values(&open_blockers, 3),
12083 open_blockers.len(),
12084 dependents.len()
12085 )
12086 };
12087 let status_line = format!(
12088 "Status: {} | Priority: p{} | Type: {} | State: {}",
12089 issue.status,
12090 issue.priority,
12091 display_or_fallback(&issue.issue_type, "unknown"),
12092 action_state
12093 );
12094 let context_line = format!(
12095 "Assignee: {} | Repo: {} | Estimate: {}",
12096 display_or_fallback(&issue.assignee, "unassigned"),
12097 display_or_fallback(&issue.source_repo, "local"),
12098 issue
12099 .estimated_minutes
12100 .map_or_else(|| "n/a".to_string(), |minutes| format!("{minutes}m"))
12101 );
12102 let closed_display = if issue.is_closed_like() {
12103 format_compact_timestamp(issue.closed_at.or(issue.updated_at))
12104 } else {
12105 "n/a".to_string()
12106 };
12107 let timeline_line = format!(
12108 "Created: {} | Updated: {} | Due: {}",
12109 format_compact_timestamp(issue.created_at),
12110 format_compact_timestamp(issue.updated_at),
12111 format_compact_timestamp(issue.due_date)
12112 );
12113 let signal_summary = format!(
12114 "Depth {depth} | k-core {k_core} | slack {slack:.4} | cut-point {}",
12115 if articulation { "YES" } else { "no" }
12116 );
12117 let external_ref = self.selected_issue_external_ref_url();
12118
12119 let mut lines = Vec::new();
12120 let push_module_header = |lines: &mut Vec<RichLine>, title: &str, subtitle: &str| {
12121 if !lines.is_empty() {
12122 lines.push(RichLine::raw(""));
12123 }
12124 lines.push(section_separator(48));
12125 lines.push(panel_header(title, Some(subtitle)));
12126 };
12127
12128 push_module_header(&mut lines, "Summary", action_subtitle);
12129 lines.push(RichLine::from_spans([
12130 RichSpan::raw(format!(
12131 "{} {} {}",
12132 type_icon(&issue.issue_type),
12133 issue.id,
12134 issue.title
12135 )),
12136 RichSpan::styled(" ", tokens::dim()),
12137 RichSpan::styled("(C copy id)", tokens::dim()),
12138 ]));
12139 if let Some(styled_line) = styled_detail_summary_line(&status_line) {
12140 lines.push(styled_line);
12141 }
12142 lines.push(RichLine::from_spans([RichSpan::styled(
12143 action_line,
12144 tokens::panel_title_focused(),
12145 )]));
12146 lines.push(RichLine::from_spans([
12147 RichSpan::raw(&context_line),
12148 RichSpan::styled(" ", tokens::dim()),
12149 RichSpan::styled("(w repo filter)", tokens::dim()),
12150 ]));
12151
12152 let mut labels_line = RichLine::new();
12153 labels_line.push_span(RichSpan::raw(format!(
12154 "Closed: {closed_display} | Labels: "
12155 )));
12156 if issue.labels.is_empty() {
12157 labels_line.push_span(RichSpan::styled("none", tokens::dim()));
12158 } else {
12159 for span in label_chips(&issue.labels) {
12160 labels_line.push_span(span);
12161 }
12162 }
12163 labels_line.push_span(RichSpan::styled(" ", tokens::dim()));
12164 labels_line.push_span(RichSpan::styled("(L label filter)", tokens::dim()));
12165 lines.push(labels_line);
12166 lines.push(RichLine::from_spans([
12167 RichSpan::raw(&timeline_line),
12168 RichSpan::styled(" ", tokens::dim()),
12169 RichSpan::styled("(t time-travel)", tokens::dim()),
12170 ]));
12171 if let Some(url) = external_ref {
12172 lines.push(RichLine::from_spans([
12173 RichSpan::raw("External: "),
12174 RichSpan::styled(url, tokens::panel_title_focused()).link(url),
12175 RichSpan::styled(" ", tokens::dim()),
12176 RichSpan::styled("(o open, y copy)", tokens::dim()),
12177 ]));
12178 }
12179
12180 push_module_header(&mut lines, "Signals", "rank and graph pressure");
12181 lines.push(RichLine::raw(signal_summary));
12182 let mut primary_metrics = RichLine::new();
12183 for span in metric_strip("PR", pagerank, pr_max) {
12184 primary_metrics.push_span(span);
12185 }
12186 primary_metrics.push_span(RichSpan::styled(" ", tokens::dim()));
12187 for span in metric_strip("BW", betweenness, bw_max) {
12188 primary_metrics.push_span(span);
12189 }
12190 lines.push(primary_metrics);
12191 let mut secondary_metrics = RichLine::new();
12192 for span in metric_strip("EV", eigenvector, ev_max) {
12193 secondary_metrics.push_span(span);
12194 }
12195 secondary_metrics.push_span(RichSpan::styled(" ", tokens::dim()));
12196 secondary_metrics.push_span(RichSpan::styled(
12197 format!(
12198 "blockers={} | unblocks={}",
12199 open_blockers.len(),
12200 dependents.len()
12201 ),
12202 tokens::dim(),
12203 ));
12204 lines.push(secondary_metrics);
12205
12206 push_module_header(&mut lines, "Dependencies", "upstream, gates, downstream");
12207 lines.push(RichLine::raw(format!(
12208 "Upstream: {}",
12209 join_display_values(&blockers, 4)
12210 )));
12211 lines.push(RichLine::raw(format!(
12212 "Open Gate: {}",
12213 join_display_values(&open_blockers, 4)
12214 )));
12215 lines.push(RichLine::raw(format!(
12216 "Downstream: {}",
12217 join_display_values(&dependents, 4)
12218 )));
12219
12220 if self.priority_hints_visible {
12221 push_module_header(&mut lines, "Priority Hints", "scoring rationale");
12222 let mut hint_lines = Vec::new();
12223 self.append_priority_hints(&mut hint_lines, issue);
12224 for line in hint_lines {
12225 if let Some(styled_line) = styled_detail_summary_line(&line) {
12226 lines.push(styled_line);
12227 } else {
12228 lines.push(RichLine::raw(line));
12229 }
12230 }
12231 }
12232
12233 for line in self.issue_detail_text().lines() {
12234 if matches!(
12235 line,
12236 "Triage Snapshot:" | "Graph Signals:" | "Dependency Map:"
12237 ) {
12238 continue;
12239 }
12240 if line.starts_with(" open blockers:")
12241 || line.starts_with(" unblocks:")
12242 || line.starts_with(" dependency pressure:")
12243 || line.starts_with(" cycle time:")
12244 || line.starts_with(" Critical depth:")
12245 || line.starts_with(" PageRank:")
12246 || line.starts_with(" Betweenness:")
12247 || line.starts_with(" Eigenvector:")
12248 || line.starts_with(" HITS:")
12249 || line.starts_with(" upstream:")
12250 || line.starts_with(" open gate:")
12251 || line.starts_with(" downstream:")
12252 || line == status_line
12253 || line == context_line
12254 || line == timeline_line
12255 || line.starts_with("Closed: ")
12256 || line.starts_with("External: ")
12257 || line
12258 == format!(
12259 "{} {} {}",
12260 type_icon(&issue.issue_type),
12261 issue.id,
12262 issue.title
12263 )
12264 {
12265 continue;
12266 }
12267
12268 if line.ends_with(':') && !line.is_empty() {
12269 lines.push(RichLine::raw(""));
12270 lines.push(section_separator(48));
12271 lines.push(panel_header(line.trim_end_matches(':'), None));
12272 } else if let Some(styled_line) = styled_detail_summary_line(line) {
12273 lines.push(styled_line);
12274 } else {
12275 lines.push(RichLine::raw(line));
12276 }
12277 }
12278
12279 if let Some(history) = history.as_ref()
12280 && !history.events.is_empty()
12281 && !self.issue_detail_text().contains(&format!(
12282 "History Summary ({} events):",
12283 history.events.len()
12284 ))
12285 {
12286 lines.push(RichLine::raw(""));
12287 lines.push(section_separator(48));
12288 lines.push(panel_header("History Summary", Some("recent lifecycle")));
12289 }
12290
12291 RichText::from_lines(lines)
12292 }
12293
12294 fn graph_detail_render_text(&self) -> RichText {
12295 let external_ref = self.selected_issue_external_ref_url();
12296 let graph_link_insert_after = 4usize;
12297 let mut lines = Vec::new();
12298 for (index, line) in self.graph_detail_text().lines().enumerate() {
12299 if let Some(url) = external_ref
12300 && index == graph_link_insert_after
12301 {
12302 lines.push(RichLine::from_spans([
12303 RichSpan::raw("External Link: "),
12304 RichSpan::styled(url, tokens::panel_title_focused()).link(url),
12305 RichSpan::styled(" ", tokens::dim()),
12306 RichSpan::styled("(o open, y copy)", tokens::dim()),
12307 ]));
12308 lines.push(RichLine::raw(""));
12309 }
12310
12311 if let Some(url) = external_ref
12312 && line
12313 .strip_prefix("External: ")
12314 .is_some_and(|rendered| rendered == url || rendered.ends_with('…'))
12315 {
12316 continue;
12317 } else if let Some(styled_line) = styled_detail_summary_line(line) {
12318 lines.push(styled_line);
12319 continue;
12320 }
12321
12322 lines.push(RichLine::raw(line));
12323 }
12324 if let Some(url) = external_ref
12325 && self.graph_detail_text().lines().count() <= graph_link_insert_after
12326 {
12327 lines.push(RichLine::raw(""));
12328 lines.push(RichLine::from_spans([
12329 RichSpan::raw("External Link: "),
12330 RichSpan::styled(url, tokens::panel_title_focused()).link(url),
12331 RichSpan::styled(" ", tokens::dim()),
12332 RichSpan::styled("(o open, y copy)", tokens::dim()),
12333 ]));
12334 }
12335 RichText::from_lines(lines)
12336 }
12337
12338 fn board_detail_render_text(&self) -> RichText {
12339 let external_ref = self.selected_issue_external_ref_url();
12340 let mut lines = Vec::new();
12341 let mut inserted_link = false;
12342
12343 for line in self.board_detail_text().lines() {
12344 if let Some(url) = external_ref
12345 && !inserted_link
12346 && line.is_empty()
12347 {
12348 lines.push(RichLine::from_spans([
12349 RichSpan::raw("External Link: "),
12350 RichSpan::styled(url, tokens::panel_title_focused()).link(url),
12351 RichSpan::styled(" ", tokens::dim()),
12352 RichSpan::styled("(o open, y copy)", tokens::dim()),
12353 ]));
12354 lines.push(RichLine::raw(""));
12355 inserted_link = true;
12356 }
12357
12358 lines.push(RichLine::raw(line));
12359 }
12360
12361 if let Some(url) = external_ref
12362 && !inserted_link
12363 {
12364 lines.push(RichLine::raw(""));
12365 lines.push(RichLine::from_spans([
12366 RichSpan::raw("External Link: "),
12367 RichSpan::styled(url, tokens::panel_title_focused()).link(url),
12368 RichSpan::styled(" ", tokens::dim()),
12369 RichSpan::styled("(o open, y copy)", tokens::dim()),
12370 ]));
12371 }
12372
12373 RichText::from_lines(lines)
12374 }
12375
12376 #[cfg(test)]
12377 fn board_detail_render_state(&self, visible_height: usize) -> (String, usize, usize) {
12378 let full_text = self.board_detail_text();
12379 let total_lines = full_text.lines().count();
12380 if visible_height == 0 {
12381 return (String::new(), 0, total_lines);
12382 }
12383
12384 let max_offset = total_lines.saturating_sub(visible_height);
12385 let offset = self.board_detail_scroll_offset.min(max_offset);
12386 if offset == 0 {
12387 return (full_text, 0, total_lines);
12388 }
12389
12390 let visible = full_text
12391 .lines()
12392 .skip(offset)
12393 .collect::<Vec<_>>()
12394 .join("\n");
12395 (visible, offset, total_lines)
12396 }
12397
12398 fn issue_detail_text(&self) -> String {
12399 if self.analyzer.issues.is_empty() {
12400 return "No issues to display. Create or load a .beads/*.jsonl dataset.".to_string();
12401 }
12402
12403 let Some(issue) = self.selected_issue() else {
12404 return self.no_filtered_issues_text("main detail");
12405 };
12406 let blockers = self.analyzer.graph.blockers(&issue.id);
12407 let open_blockers = self.analyzer.graph.open_blockers(&issue.id);
12408 let dependents = self.analyzer.graph.dependents(&issue.id);
12409 let betweenness = self
12410 .analyzer
12411 .metrics
12412 .betweenness
12413 .get(&issue.id)
12414 .copied()
12415 .unwrap_or_default();
12416 let eigenvector = self
12417 .analyzer
12418 .metrics
12419 .eigenvector
12420 .get(&issue.id)
12421 .copied()
12422 .unwrap_or_default();
12423 let hubs = self
12424 .analyzer
12425 .metrics
12426 .hubs
12427 .get(&issue.id)
12428 .copied()
12429 .unwrap_or_default();
12430 let authorities = self
12431 .analyzer
12432 .metrics
12433 .authorities
12434 .get(&issue.id)
12435 .copied()
12436 .unwrap_or_default();
12437 let k_core = self
12438 .analyzer
12439 .metrics
12440 .k_core
12441 .get(&issue.id)
12442 .copied()
12443 .unwrap_or_default();
12444 let slack = self
12445 .analyzer
12446 .metrics
12447 .slack
12448 .get(&issue.id)
12449 .copied()
12450 .unwrap_or_default();
12451 let pagerank = self
12452 .analyzer
12453 .metrics
12454 .pagerank
12455 .get(&issue.id)
12456 .copied()
12457 .unwrap_or_default();
12458 let depth = self
12459 .analyzer
12460 .metrics
12461 .critical_depth
12462 .get(&issue.id)
12463 .copied()
12464 .unwrap_or_default();
12465 let articulation = self
12466 .analyzer
12467 .metrics
12468 .articulation_points
12469 .contains(&issue.id);
12470 let history = self.analyzer.history(Some(&issue.id), 1).into_iter().next();
12471
12472 let pr_rank = metric_rank(&self.analyzer.metrics.pagerank, &issue.id);
12473 let bw_rank = metric_rank(&self.analyzer.metrics.betweenness, &issue.id);
12474 let ev_rank = metric_rank(&self.analyzer.metrics.eigenvector, &issue.id);
12475 let hub_rank = metric_rank(&self.analyzer.metrics.hubs, &issue.id);
12476 let auth_rank = metric_rank(&self.analyzer.metrics.authorities, &issue.id);
12477 let pr_max = max_metric_value(&self.analyzer.metrics.pagerank);
12478 let bw_max = max_metric_value(&self.analyzer.metrics.betweenness);
12479 let ev_max = max_metric_value(&self.analyzer.metrics.eigenvector);
12480 let hub_max = max_metric_value(&self.analyzer.metrics.hubs);
12481 let auth_max = max_metric_value(&self.analyzer.metrics.authorities);
12482 let action_state = if issue.is_closed_like() {
12483 "closed"
12484 } else if open_blockers.is_empty() {
12485 "ready"
12486 } else {
12487 "blocked"
12488 };
12489 let closed_display = if issue.is_closed_like() {
12490 format_compact_timestamp(issue.closed_at.or(issue.updated_at))
12491 } else {
12492 "n/a".to_string()
12493 };
12494
12495 let mut lines = vec![
12496 format!(
12497 "{} {} {}",
12498 type_icon(&issue.issue_type),
12499 issue.id,
12500 issue.title
12501 ),
12502 format!(
12503 "Status: {} | Priority: p{} | Type: {} | State: {}",
12504 issue.status,
12505 issue.priority,
12506 display_or_fallback(&issue.issue_type, "unknown"),
12507 action_state
12508 ),
12509 format!(
12510 "Assignee: {} | Repo: {} | Estimate: {}",
12511 display_or_fallback(&issue.assignee, "unassigned"),
12512 display_or_fallback(&issue.source_repo, "local"),
12513 issue
12514 .estimated_minutes
12515 .map_or_else(|| "n/a".to_string(), |minutes| format!("{minutes}m"))
12516 ),
12517 format!(
12518 "Created: {} | Updated: {} | Due: {}",
12519 format_compact_timestamp(issue.created_at),
12520 format_compact_timestamp(issue.updated_at),
12521 format_compact_timestamp(issue.due_date)
12522 ),
12523 format!(
12524 "Closed: {} | Labels: {}",
12525 closed_display,
12526 join_display_values(&issue.labels, 4)
12527 ),
12528 ];
12529 if let Some(ref ext_ref) = issue.external_ref {
12530 lines.push(format!("External: {ext_ref}"));
12531 }
12532 lines.extend([
12533 String::new(),
12534 "Triage Snapshot:".to_string(),
12535 format!(
12536 " open blockers: {} ({})",
12537 open_blockers.len(),
12538 join_display_values(&open_blockers, 4)
12539 ),
12540 format!(
12541 " unblocks: {} ({})",
12542 dependents.len(),
12543 join_display_values(&dependents, 4)
12544 ),
12545 format!(
12546 " dependency pressure: {} upstream | {} downstream",
12547 blockers.len(),
12548 dependents.len()
12549 ),
12550 ]);
12551
12552 if let (Some(created), Some(closed)) = (issue.created_at, issue.closed_at) {
12553 let duration = closed - created;
12554 lines.push(format!(
12555 " cycle time: {}d {}h",
12556 duration.num_days(),
12557 duration.num_hours() - duration.num_days() * 24
12558 ));
12559 }
12560
12561 lines.push(String::new());
12562 lines.push("Graph Signals:".to_string());
12563 lines.push(format!(
12564 " Critical depth: {depth} | k-core: {k_core} | slack: {slack:.4} | cut-point: {}",
12565 if articulation { "YES" } else { "no" }
12566 ));
12567 lines.push(format!(
12568 " PageRank: {pagerank:>8.4} {} #{pr_rank}",
12569 mini_bar(pagerank, pr_max)
12570 ));
12571 lines.push(format!(
12572 " Betweenness: {betweenness:>8.4} {} #{bw_rank}",
12573 mini_bar(betweenness, bw_max)
12574 ));
12575 lines.push(format!(
12576 " Eigenvector: {eigenvector:>8.4} {} #{ev_rank}",
12577 mini_bar(eigenvector, ev_max)
12578 ));
12579 lines.push(format!(
12580 " HITS: hub {hubs:.4} {} #{hub_rank} | auth {authorities:.4} {} #{auth_rank}",
12581 mini_bar(hubs, hub_max),
12582 mini_bar(authorities, auth_max)
12583 ));
12584
12585 push_text_section(&mut lines, "Description", &issue.description);
12586 push_text_section(&mut lines, "Design Notes", &issue.design);
12587 push_text_section(
12588 &mut lines,
12589 "Acceptance Criteria",
12590 &issue.acceptance_criteria,
12591 );
12592 push_text_section(&mut lines, "Notes", &issue.notes);
12593
12594 lines.push(String::new());
12595 lines.push("Dependency Map:".to_string());
12596 lines.push(format!(" upstream: {}", join_display_values(&blockers, 4)));
12597 lines.push(format!(
12598 " open gate: {}",
12599 join_display_values(&open_blockers, 4)
12600 ));
12601 lines.push(format!(
12602 " downstream: {}",
12603 join_display_values(&dependents, 4)
12604 ));
12605
12606 if self.priority_hints_visible {
12607 self.append_priority_hints(&mut lines, issue);
12608 }
12609
12610 push_comment_section(&mut lines, issue);
12611 push_history_section(&mut lines, history.as_ref());
12612 lines.join("\n")
12613 }
12614
12615 fn append_priority_hints(&self, lines: &mut Vec<String>, issue: &crate::model::Issue) {
12616 use crate::analysis::triage::{TriageOptions, TriageScoringOptions};
12617
12618 lines.push(String::new());
12619 lines.push("Priority Hints (p to hide):".to_string());
12620
12621 let triage = self.analyzer.triage(TriageOptions {
12622 max_recommendations: 200,
12623 scoring: TriageScoringOptions::default(),
12624 ..TriageOptions::default()
12625 });
12626
12627 if let Some(rec) = triage
12628 .result
12629 .recommendations
12630 .iter()
12631 .find(|r| r.id == issue.id)
12632 {
12633 lines.push(format!(" Triage Score: {:.3}", rec.score));
12634 lines.push(format!(" Confidence: {:.1}%", rec.confidence * 100.0));
12635 lines.push(format!(" Unblocks: {}", rec.unblocks));
12636 if !rec.reasons.is_empty() {
12637 lines.push(format!(" Reasons: {}", rec.reasons.join("; ")));
12638 }
12639
12640 if let Some(ref breakdown) = rec.breakdown {
12641 lines.push(String::new());
12642 lines.push(" Score Breakdown:".to_string());
12643 for component in breakdown {
12644 let bar = mini_bar(component.weighted, 0.3);
12645 lines.push(format!(
12646 " {:<14} {:>5.1}% × {:.3} = {:.4} {bar}",
12647 component.name,
12648 component.weight * 100.0,
12649 component.normalized,
12650 component.weighted,
12651 ));
12652 if let Some(ref explanation) = component.explanation {
12653 lines.push(format!(" └ {explanation}"));
12654 }
12655 }
12656 }
12657
12658 lines.push(format!(" Claim: {}", rec.claim_command));
12659 } else {
12660 lines.push(" (not in triage — may be closed or blocked)".to_string());
12661 }
12662 }
12663
12664 fn board_detail_text(&self) -> String {
12665 if self.analyzer.issues.is_empty() {
12666 return "No issues to display in board mode.".to_string();
12667 }
12668
12669 let Some(issue) = self.selected_issue() else {
12670 return self.no_filtered_issues_text("board mode");
12671 };
12672
12673 let blockers = self.analyzer.graph.blockers(&issue.id);
12674 let dependents = self.analyzer.graph.dependents(&issue.id);
12675 let open_blockers = self.analyzer.graph.open_blockers(&issue.id);
12676 let icon = status_icon(&issue.status);
12677 let ti = type_icon(&issue.issue_type);
12678
12679 let lane_label = match self.board_grouping {
12681 BoardGrouping::Status => issue.status.clone(),
12682 BoardGrouping::Priority => format!("p{}", issue.priority),
12683 BoardGrouping::Type => {
12684 if issue.issue_type.trim().is_empty() {
12685 "unknown".to_string()
12686 } else {
12687 issue.issue_type.to_lowercase()
12688 }
12689 }
12690 };
12691
12692 let title_trunc = truncate_str(&issue.title, 34);
12693 let id_line = format!(" {} {} {} p{}", icon, ti, issue.id, issue.priority);
12694 let box_width = 40;
12695 let hrule: String = std::iter::repeat_n('\u{2500}', box_width).collect();
12696
12697 let mut out = Vec::<String>::new();
12698 out.push(format!("\u{250c}{hrule}\u{2510}"));
12699 out.push(format!(
12700 "\u{2502} {:<w$}\u{2502}",
12701 id_line,
12702 w = box_width - 1
12703 ));
12704 out.push(format!(
12705 "\u{2502} {:<w$}\u{2502}",
12706 title_trunc,
12707 w = box_width - 1
12708 ));
12709 out.push(format!("\u{251c}{hrule}\u{2524}"));
12710 out.push(format!(
12711 "\u{2502} Lane: {:<w$}\u{2502}",
12712 lane_label,
12713 w = box_width - 7
12714 ));
12715 out.push(format!(
12716 "\u{2502} Assignee: {:<w$}\u{2502}",
12717 issue.assignee,
12718 w = box_width - 11
12719 ));
12720 if !issue.labels.is_empty() {
12722 let labels_str = issue
12723 .labels
12724 .iter()
12725 .take(4)
12726 .cloned()
12727 .collect::<Vec<_>>()
12728 .join(", ");
12729 let labels_display = if issue.labels.len() > 4 {
12730 format!("{labels_str} +{}", issue.labels.len() - 4)
12731 } else {
12732 labels_str
12733 };
12734 out.push(format!(
12735 "\u{2502} Labels: {:<w$}\u{2502}",
12736 truncate_str(&labels_display, box_width - 9),
12737 w = box_width - 9
12738 ));
12739 }
12740 if let Some(created) = issue.created_at {
12742 let reference_now = ui_reference_now();
12743 let age = (reference_now - created).num_days();
12744 let updated_age = issue.updated_at.map_or_else(
12745 || "never".to_string(),
12746 |u| format!("{}d ago", (reference_now - u).num_days()),
12747 );
12748 out.push(format!(
12749 "\u{2502} Age: {age}d | Updated: {:<w$}\u{2502}",
12750 updated_age,
12751 w = box_width - 21
12752 ));
12753 }
12754 let pr = self
12756 .analyzer
12757 .metrics
12758 .pagerank
12759 .get(&issue.id)
12760 .copied()
12761 .unwrap_or(0.0);
12762 let depth = self
12763 .analyzer
12764 .metrics
12765 .critical_depth
12766 .get(&issue.id)
12767 .copied()
12768 .unwrap_or(0);
12769 if pr > 0.0 || depth > 0 {
12770 out.push(format!(
12771 "\u{2502} PR:{:.3} Depth:{} {:<w$}\u{2502}",
12772 pr,
12773 depth,
12774 if self
12775 .analyzer
12776 .metrics
12777 .articulation_points
12778 .contains(&issue.id)
12779 {
12780 "\u{25c6}cut"
12781 } else {
12782 ""
12783 },
12784 w = box_width - 18
12785 ));
12786 }
12787 if !issue.description.is_empty() {
12789 out.push(format!("\u{251c}{hrule}\u{2524}"));
12790 for line in issue.description.lines().take(3) {
12792 out.push(format!(
12793 "\u{2502} {:<w$}\u{2502}",
12794 truncate_str(line.trim(), box_width - 3),
12795 w = box_width - 1
12796 ));
12797 }
12798 if issue.description.lines().count() > 3 {
12799 out.push(format!("\u{2502} {:<w$}\u{2502}", "...", w = box_width - 1));
12800 }
12801 }
12802 out.push(format!("\u{2514}{hrule}\u{2518}"));
12803
12804 out.push(String::new());
12805 let mut dep_index = 0usize;
12807 let show_cursor = self.focus == FocusPane::Detail;
12808 if !blockers.is_empty() {
12809 out.push(format!("Depends on ({})", blockers.len()));
12810 for bid in &blockers {
12811 let bstatus = self
12812 .analyzer
12813 .issues
12814 .iter()
12815 .find(|i| i.id == *bid)
12816 .map_or("?", |i| status_icon(&i.status));
12817 let is_open = open_blockers.contains(bid);
12818 let marker = if is_open { "OPEN" } else { "ok" };
12819 let prefix = if show_cursor && dep_index == self.detail_dep_cursor {
12820 ">"
12821 } else {
12822 " "
12823 };
12824 out.push(format!("{prefix} {bstatus} {bid} [{marker}]"));
12825 dep_index += 1;
12826 }
12827 }
12828 if !dependents.is_empty() {
12829 out.push(format!("Unblocks ({})", dependents.len()));
12830 for did in &dependents {
12831 let dstatus = self
12832 .analyzer
12833 .issues
12834 .iter()
12835 .find(|i| i.id == *did)
12836 .map_or("?", |i| status_icon(&i.status));
12837 let prefix = if show_cursor && dep_index == self.detail_dep_cursor {
12838 ">"
12839 } else {
12840 " "
12841 };
12842 out.push(format!("{prefix} {dstatus} {did}"));
12843 dep_index += 1;
12844 }
12845 }
12846
12847 out.push(String::new());
12848 if open_blockers.is_empty() {
12849 out.push("Ready to advance to next lane.".to_string());
12850 } else {
12851 out.push(format!("Blocked by {} open issue(s).", open_blockers.len()));
12852 }
12853
12854 out.join("\n")
12855 }
12856
12857 fn insights_detail_text(&self) -> String {
12858 let heatmap_context = self.insights_heatmap.as_ref().map(|state| {
12859 let data = self.insights_heatmap_data();
12860 let row = state
12861 .row
12862 .min(INSIGHTS_HEATMAP_DEPTH_LABELS.len().saturating_sub(1));
12863 let col = state
12864 .col
12865 .min(INSIGHTS_HEATMAP_SCORE_LABELS.len().saturating_sub(1));
12866 let cell_issue_ids = data.issue_ids[row][col].clone();
12867
12868 let mut context = vec![format!(
12869 "Heatmap: {} x {} ({} issue(s))",
12870 INSIGHTS_HEATMAP_DEPTH_LABELS[row],
12871 INSIGHTS_HEATMAP_SCORE_LABELS[col],
12872 cell_issue_ids.len()
12873 )];
12874 if state.drill_active {
12875 let position = if cell_issue_ids.is_empty() {
12876 0
12877 } else {
12878 state
12879 .drill_cursor
12880 .min(cell_issue_ids.len().saturating_sub(1))
12881 + 1
12882 };
12883 context.push(format!(
12884 "Drill selection: {position}/{}",
12885 cell_issue_ids.len()
12886 ));
12887 }
12888
12889 (context, cell_issue_ids)
12890 });
12891
12892 if self.analyzer.issues.is_empty() {
12893 return "No insights available.".to_string();
12894 }
12895
12896 if let Some((mut context, cell_issue_ids)) = heatmap_context.clone()
12897 && cell_issue_ids.is_empty()
12898 {
12899 context.push(String::new());
12900 context.push("No issues in the selected heatmap cell.".to_string());
12901 return context.join("\n");
12902 }
12903
12904 let Some(issue) = self.selected_issue() else {
12905 if let Some((mut context, _)) = heatmap_context {
12906 context.push(String::new());
12907 context.push(self.no_filtered_issues_text("insights mode"));
12908 return context.join("\n");
12909 }
12910 return self.no_filtered_issues_text("insights mode");
12911 };
12912 let insights = self.analyzer.insights();
12913 let pagerank = self
12914 .analyzer
12915 .metrics
12916 .pagerank
12917 .get(&issue.id)
12918 .copied()
12919 .unwrap_or_default();
12920 let betweenness = self
12921 .analyzer
12922 .metrics
12923 .betweenness
12924 .get(&issue.id)
12925 .copied()
12926 .unwrap_or_default();
12927 let eigenvector = self
12928 .analyzer
12929 .metrics
12930 .eigenvector
12931 .get(&issue.id)
12932 .copied()
12933 .unwrap_or_default();
12934 let hubs = self
12935 .analyzer
12936 .metrics
12937 .hubs
12938 .get(&issue.id)
12939 .copied()
12940 .unwrap_or_default();
12941 let authorities = self
12942 .analyzer
12943 .metrics
12944 .authorities
12945 .get(&issue.id)
12946 .copied()
12947 .unwrap_or_default();
12948 let k_core = self
12949 .analyzer
12950 .metrics
12951 .k_core
12952 .get(&issue.id)
12953 .copied()
12954 .unwrap_or_default();
12955 let slack = self
12956 .analyzer
12957 .metrics
12958 .slack
12959 .get(&issue.id)
12960 .copied()
12961 .unwrap_or_default();
12962 let depth = self
12963 .analyzer
12964 .metrics
12965 .critical_depth
12966 .get(&issue.id)
12967 .copied()
12968 .unwrap_or_default();
12969 let blockers = self.analyzer.graph.blockers(&issue.id);
12970 let dependents = self.analyzer.graph.dependents(&issue.id);
12971 let articulation = self
12972 .analyzer
12973 .metrics
12974 .articulation_points
12975 .contains(&issue.id);
12976 let in_critical_path = insights.critical_path.iter().any(|id| id == &issue.id);
12977 let in_cycle = insights
12978 .cycles
12979 .iter()
12980 .any(|cycle| cycle.iter().any(|id| id == &issue.id));
12981 let top_bottleneck = insights
12982 .bottlenecks
12983 .first()
12984 .is_some_and(|item| item.id == issue.id);
12985
12986 let mut lines = vec![
12987 "Analytics Cockpit".to_string(),
12988 format!(
12989 "System Radar | bottlenecks={} crit-path={} cycles={} cut-pts={} k-core-max={}",
12990 insights.bottlenecks.len(),
12991 insights.critical_path.len(),
12992 insights.cycles.len(),
12993 insights.articulation_points.len(),
12994 insights.cores.first().map_or(0, |c| c.value)
12995 ),
12996 String::new(),
12997 format!("Focus: {} ({})", issue.id, issue.title),
12998 format!("Status: {} | Priority: p{}", issue.status, issue.priority),
12999 String::new(),
13000 "Metric Strip".to_string(),
13001 format!(
13002 "[Rank ] pagerank={pagerank:.4} betweenness={betweenness:.4} eigenvector={eigenvector:.4}"
13003 ),
13004 format!("[HITS ] hub={hubs:.4} authority={authorities:.4} k-core={k_core}"),
13005 format!(
13006 "[Risk ] crit-depth={depth} slack={slack:.4} cut-point={}",
13007 if articulation { "yes" } else { "no" }
13008 ),
13009 format!(
13010 "[Flow ] blockers={} dependents={} crit-path={} cycle={}",
13011 blockers.len(),
13012 dependents.len(),
13013 if in_critical_path { "yes" } else { "no" },
13014 if in_cycle { "yes" } else { "no" }
13015 ),
13016 format!(
13017 "[Lead ] top-bottleneck={}",
13018 if top_bottleneck { "yes" } else { "no" }
13019 ),
13020 String::new(),
13021 "All Metrics:".to_string(),
13022 format!(" PageRank: {:.4}", pagerank),
13023 format!(" Betweenness: {:.4}", betweenness),
13024 format!(" Eigenvector: {:.4}", eigenvector),
13025 format!(" Hub (HITS): {:.4}", hubs),
13026 format!(" Auth (HITS): {:.4}", authorities),
13027 format!(" K-core: {}", k_core),
13028 format!(" Crit depth: {}", depth),
13029 format!(" Slack: {:.4}", slack),
13030 format!(
13031 " Cut point: {}",
13032 if articulation { "YES" } else { "no" }
13033 ),
13034 ];
13035
13036 lines.push(String::new());
13037 if self.insights_show_explanations {
13038 lines.push("Critical Path Head:".to_string());
13039 if insights.critical_path.is_empty() {
13040 lines.push(" none".to_string());
13041 } else {
13042 lines.extend(
13043 insights
13044 .critical_path
13045 .iter()
13046 .take(6)
13047 .map(|id| format!(" - {id}")),
13048 );
13049 }
13050
13051 lines.push(String::new());
13052 lines.push("Cycle Hotspots:".to_string());
13053 if insights.cycles.is_empty() {
13054 lines.push(" none".to_string());
13055 } else {
13056 lines.extend(
13057 insights
13058 .cycles
13059 .iter()
13060 .take(4)
13061 .map(|cycle| format!(" - {}", cycle.join(" -> "))),
13062 );
13063 }
13064 } else {
13065 lines.push("Explanations hidden (press e to show).".to_string());
13066 }
13067
13068 if self.insights_show_calc_proof {
13069 let blocks_count = self
13070 .analyzer
13071 .metrics
13072 .blocks_count
13073 .get(&issue.id)
13074 .copied()
13075 .unwrap_or_default();
13076 let blocked_by_count = self
13077 .analyzer
13078 .metrics
13079 .blocked_by_count
13080 .get(&issue.id)
13081 .copied()
13082 .unwrap_or_default();
13083 lines.push(String::new());
13084 lines.push("Calculation Proof:".to_string());
13085 lines.push(format!(
13086 " score inputs -> blocks={blocks_count} blocked_by={blocked_by_count} pagerank={pagerank:.4} betweenness={betweenness:.4} depth={depth}",
13087 ));
13088 }
13089
13090 if !blockers.is_empty() || !dependents.is_empty() {
13092 let show_cursor = self.focus == FocusPane::Detail;
13093 let mut dep_index = 0usize;
13094 lines.push(String::new());
13095 if !blockers.is_empty() {
13096 lines.push(format!("Depends on ({})", blockers.len()));
13097 for bid in &blockers {
13098 let bsi = self
13099 .analyzer
13100 .issues
13101 .iter()
13102 .find(|i| i.id == *bid)
13103 .map_or("?", |i| status_icon(&i.status));
13104 let prefix = if show_cursor && dep_index == self.detail_dep_cursor {
13105 ">"
13106 } else {
13107 " "
13108 };
13109 lines.push(format!("{prefix} {bsi} {bid}"));
13110 dep_index += 1;
13111 }
13112 }
13113 if !dependents.is_empty() {
13114 lines.push(format!("Unblocks ({})", dependents.len()));
13115 for did in &dependents {
13116 let dsi = self
13117 .analyzer
13118 .issues
13119 .iter()
13120 .find(|i| i.id == *did)
13121 .map_or("?", |i| status_icon(&i.status));
13122 let prefix = if show_cursor && dep_index == self.detail_dep_cursor {
13123 ">"
13124 } else {
13125 " "
13126 };
13127 lines.push(format!("{prefix} {dsi} {did}"));
13128 dep_index += 1;
13129 }
13130 }
13131 }
13132
13133 if let Some((mut context, _)) = heatmap_context {
13134 context.push(String::new());
13135 context.append(&mut lines);
13136 return context.join("\n");
13137 }
13138
13139 lines.join("\n")
13140 }
13141
13142 fn insights_detail_render_text(&self) -> RichText {
13143 let external_ref = self.selected_issue_external_ref_url();
13144 let insights_link_insert_after = 4usize;
13145 let mut lines = Vec::new();
13146 let mut inserted_link = false;
13147 for (index, line) in self.insights_detail_text().lines().enumerate() {
13148 if let Some(url) = external_ref
13149 && index == insights_link_insert_after
13150 {
13151 lines.push(RichLine::from_spans([
13152 RichSpan::raw("External Link: "),
13153 RichSpan::styled(url, tokens::panel_title_focused()).link(url),
13154 RichSpan::styled(" ", tokens::dim()),
13155 RichSpan::styled("(o open, y copy)", tokens::dim()),
13156 ]));
13157 lines.push(RichLine::raw(""));
13158 inserted_link = true;
13159 }
13160
13161 lines.push(RichLine::raw(line));
13162 }
13163
13164 if let Some(url) = external_ref
13165 && !inserted_link
13166 {
13167 lines.push(RichLine::raw(""));
13168 lines.push(RichLine::from_spans([
13169 RichSpan::raw("External Link: "),
13170 RichSpan::styled(url, tokens::panel_title_focused()).link(url),
13171 RichSpan::styled(" ", tokens::dim()),
13172 RichSpan::styled("(o open, y copy)", tokens::dim()),
13173 ]));
13174 }
13175
13176 RichText::from_lines(lines)
13177 }
13178
13179 fn graph_relationship_box_width(&self, width: usize, count: usize) -> usize {
13180 let count = count.clamp(1, 5);
13181 let spacing = count.saturating_sub(1);
13182 let max_fit = width.saturating_sub(spacing) / count;
13183 if max_fit >= 20 {
13184 20
13185 } else if max_fit >= 12 {
13186 max_fit
13187 } else {
13188 max_fit.max(8)
13189 }
13190 }
13191
13192 fn graph_relationship_box(
13193 &self,
13194 target_id: &str,
13195 box_width: usize,
13196 focused: bool,
13197 ) -> Vec<String> {
13198 let inner = box_width.saturating_sub(2).max(1);
13199 let (top_left, horizontal, top_right, vertical, bottom_left, bottom_right) = if focused {
13200 ('╔', '═', '╗', '║', '╚', '╝')
13201 } else {
13202 ('┌', '─', '┐', '│', '└', '┘')
13203 };
13204 let border = std::iter::repeat_n(horizontal, inner).collect::<String>();
13205
13206 let (header, title) = self.issue_by_id(target_id).map_or_else(
13207 || (format!("[?] {target_id}"), "(not in filter)".to_string()),
13208 |candidate| {
13209 let prefix = if focused { ">" } else { " " };
13210 (
13211 format!(
13212 "{prefix}[{}] {}",
13213 status_icon(&candidate.status),
13214 candidate.id
13215 ),
13216 display_or_fallback(&candidate.title, "(untitled)"),
13217 )
13218 },
13219 );
13220
13221 vec![
13222 format!("{top_left}{border}{top_right}"),
13223 format!("{vertical}{}{vertical}", fit_display(&header, inner)),
13224 format!(
13225 "{vertical}{}{vertical}",
13226 fit_display(&truncate_str(&title, inner), inner)
13227 ),
13228 format!("{bottom_left}{border}{bottom_right}"),
13229 ]
13230 }
13231
13232 fn graph_ego_box(&self, issue: &crate::model::Issue, width: usize) -> Vec<String> {
13233 let box_width = (width / 2)
13234 .clamp(22, 38)
13235 .min(width.saturating_sub(4).max(12));
13236 let inner = box_width.saturating_sub(2).max(1);
13237 let border = std::iter::repeat_n('═', inner).collect::<String>();
13238 let si = status_icon(&issue.status);
13239 let ti = type_icon(&issue.issue_type);
13240 let header = format!("[{si} {ti} p{}] {}", issue.priority, issue.id);
13241 let title = display_or_fallback(&issue.title, "(untitled)");
13242 let counts = format!(
13243 "up:{} down:{}",
13244 self.analyzer.graph.blockers(&issue.id).len(),
13245 self.analyzer.graph.dependents(&issue.id).len()
13246 );
13247
13248 vec![
13249 center_display(&format!("╔{border}╗"), width),
13250 center_display(&format!("║{}║", fit_display(&header, inner)), width),
13251 center_display(
13252 &format!("║{}║", fit_display(&truncate_str(&title, inner), inner)),
13253 width,
13254 ),
13255 center_display(&format!("║{}║", fit_display(&counts, inner)), width),
13256 center_display(&format!("╚{border}╝"), width),
13257 ]
13258 }
13259
13260 fn graph_connector_rows(&self, count: usize, width: usize) -> Vec<String> {
13261 let display_count = count.clamp(0, 5);
13262 if display_count == 0 {
13263 return Vec::new();
13264 }
13265 if display_count == 1 {
13266 return ["│", "│", "▼"]
13267 .into_iter()
13268 .map(|line| center_display(line, width))
13269 .collect();
13270 }
13271
13272 let mut fan = String::from("├");
13273 for idx in 0..display_count {
13274 if idx > 0 {
13275 fan.push('┼');
13276 }
13277 fan.push('─');
13278 }
13279 fan.push('┤');
13280
13281 ["│".to_string(), fan, "▼".to_string()]
13282 .into_iter()
13283 .map(|line| center_display(&line, width))
13284 .collect()
13285 }
13286
13287 fn graph_relationship_rows(
13288 &self,
13289 ids: &[String],
13290 width: usize,
13291 show_cursor: bool,
13292 dep_index: &mut usize,
13293 ) -> Vec<String> {
13294 if ids.is_empty() {
13295 return Vec::new();
13296 }
13297
13298 let display_count = ids.len().min(5);
13299 let box_width = self.graph_relationship_box_width(width, display_count);
13300 let boxes = ids
13301 .iter()
13302 .take(display_count)
13303 .map(|target_id| {
13304 let focused = show_cursor && *dep_index == self.detail_dep_cursor;
13305 *dep_index += 1;
13306 self.graph_relationship_box(target_id, box_width, focused)
13307 })
13308 .collect::<Vec<_>>();
13309
13310 let mut lines = center_box_rows(&boxes, width);
13311 if ids.len() > display_count {
13312 lines.push(center_display(
13313 &format!("+{} more", ids.len() - display_count),
13314 width,
13315 ));
13316 }
13317 lines
13318 }
13319
13320 fn graph_detail_text(&self) -> String {
13321 self.graph_detail_text_for_width(72)
13322 }
13323
13324 fn graph_detail_text_for_width(&self, width: usize) -> String {
13325 if self.analyzer.issues.is_empty() {
13326 return "No graph data available.".to_string();
13327 }
13328
13329 let Some(issue) = self.selected_issue() else {
13330 return self.no_filtered_issues_text("graph mode");
13331 };
13332 let render_width = width.max(24);
13333 let blockers = self.analyzer.graph.blockers(&issue.id);
13334 let dependents = self.analyzer.graph.dependents(&issue.id);
13335 let pagerank = self
13336 .analyzer
13337 .metrics
13338 .pagerank
13339 .get(&issue.id)
13340 .copied()
13341 .unwrap_or_default();
13342 let betweenness = self
13343 .analyzer
13344 .metrics
13345 .betweenness
13346 .get(&issue.id)
13347 .copied()
13348 .unwrap_or_default();
13349 let eigenvector = self
13350 .analyzer
13351 .metrics
13352 .eigenvector
13353 .get(&issue.id)
13354 .copied()
13355 .unwrap_or_default();
13356 let hubs = self
13357 .analyzer
13358 .metrics
13359 .hubs
13360 .get(&issue.id)
13361 .copied()
13362 .unwrap_or_default();
13363 let authorities = self
13364 .analyzer
13365 .metrics
13366 .authorities
13367 .get(&issue.id)
13368 .copied()
13369 .unwrap_or_default();
13370 let k_core = self
13371 .analyzer
13372 .metrics
13373 .k_core
13374 .get(&issue.id)
13375 .copied()
13376 .unwrap_or_default();
13377 let slack = self
13378 .analyzer
13379 .metrics
13380 .slack
13381 .get(&issue.id)
13382 .copied()
13383 .unwrap_or_default();
13384 let depth = self
13385 .analyzer
13386 .metrics
13387 .critical_depth
13388 .get(&issue.id)
13389 .copied()
13390 .unwrap_or_default();
13391 let articulation = self
13392 .analyzer
13393 .metrics
13394 .articulation_points
13395 .contains(&issue.id);
13396 let cycle_hits = self
13397 .analyzer
13398 .metrics
13399 .cycles
13400 .iter()
13401 .filter(|cycle| cycle.iter().any(|id| id == &issue.id))
13402 .cloned()
13403 .collect::<Vec<_>>();
13404
13405 let visible = self.graph_visible_issue_indices();
13406 let focus_position = visible
13407 .iter()
13408 .position(|&index| {
13409 self.analyzer
13410 .issues
13411 .get(index)
13412 .is_some_and(|candidate| candidate.id == issue.id)
13413 })
13414 .map_or(1, |position| position + 1);
13415 let total_focusable = visible.len().max(1);
13416 let focus_summary = match self.focus {
13417 FocusPane::Detail => {
13418 let total_edges = blockers.len() + dependents.len();
13419 if total_edges == 0 {
13420 "Focused edge: none (isolated node)".to_string()
13421 } else if self.detail_dep_cursor < blockers.len() {
13422 let target = &blockers[self.detail_dep_cursor];
13423 let title = self
13424 .issue_by_id(target)
13425 .map(|candidate| candidate.title.as_str())
13426 .unwrap_or("?");
13427 format!(
13428 "Focused edge: depends on [{}/{}] {} -> {} ({})",
13429 self.detail_dep_cursor + 1,
13430 total_edges,
13431 issue.id,
13432 target,
13433 title
13434 )
13435 } else {
13436 let dep_index = self.detail_dep_cursor - blockers.len();
13437 let target = &dependents[dep_index];
13438 let title = self
13439 .issue_by_id(target)
13440 .map(|candidate| candidate.title.as_str())
13441 .unwrap_or("?");
13442 format!(
13443 "Focused edge: unblocks [{}/{}] {} -> {} ({})",
13444 self.detail_dep_cursor + 1,
13445 total_edges,
13446 issue.id,
13447 target,
13448 title
13449 )
13450 }
13451 }
13452 FocusPane::Middle => {
13453 "Focused edge: list focus (Tab to inspect relationships)".to_string()
13454 }
13455 FocusPane::List => {
13456 "Focused edge: list focus (Tab to inspect relationships)".to_string()
13457 }
13458 };
13459
13460 let total = self.analyzer.issues.len();
13461 let cp_rank = self
13462 .analyzer
13463 .metrics
13464 .critical_depth
13465 .values()
13466 .filter(|&&value| value > depth)
13467 .count()
13468 + 1;
13469 let pr_rank = metric_rank(&self.analyzer.metrics.pagerank, &issue.id);
13470 let bw_rank = metric_rank(&self.analyzer.metrics.betweenness, &issue.id);
13471 let ev_rank = metric_rank(&self.analyzer.metrics.eigenvector, &issue.id);
13472 let hub_rank = metric_rank(&self.analyzer.metrics.hubs, &issue.id);
13473 let auth_rank = metric_rank(&self.analyzer.metrics.authorities, &issue.id);
13474 let cp_max = self
13475 .analyzer
13476 .metrics
13477 .critical_depth
13478 .values()
13479 .copied()
13480 .max()
13481 .unwrap_or_default()
13482 .max(1) as f64;
13483 let pr_max = max_metric_value(&self.analyzer.metrics.pagerank);
13484 let bw_max = max_metric_value(&self.analyzer.metrics.betweenness);
13485 let ev_max = max_metric_value(&self.analyzer.metrics.eigenvector);
13486 let hub_max = max_metric_value(&self.analyzer.metrics.hubs);
13487 let auth_max = max_metric_value(&self.analyzer.metrics.authorities);
13488
13489 let show_cursor = self.focus == FocusPane::Detail;
13490 let mut dep_index = 0usize;
13491 let mut lines = vec![
13492 format!(
13493 "Graph: nodes={} edges={} cycles={} actionable={}",
13494 self.analyzer.graph.node_count(),
13495 self.analyzer.graph.edge_count(),
13496 self.analyzer.metrics.cycles.len(),
13497 self.analyzer.graph.actionable_ids().len()
13498 ),
13499 format!(
13500 "Focus: node {focus_position}/{total_focusable} -> {} ({})",
13501 issue.id, issue.title
13502 ),
13503 focus_summary,
13504 String::new(),
13505 ];
13506
13507 if !blockers.is_empty() {
13508 lines.push(center_display(
13509 "▲ BLOCKED BY (must complete first) ▲",
13510 render_width,
13511 ));
13512 lines.extend(self.graph_relationship_rows(
13513 &blockers,
13514 render_width,
13515 show_cursor,
13516 &mut dep_index,
13517 ));
13518 lines.extend(self.graph_connector_rows(blockers.len().min(5), render_width));
13519 }
13520
13521 lines.extend(self.graph_ego_box(issue, render_width));
13522
13523 if !dependents.is_empty() {
13524 lines.extend(self.graph_connector_rows(dependents.len().min(5), render_width));
13525 lines.push(center_display("▼ BLOCKS (waiting on this) ▼", render_width));
13526 lines.extend(self.graph_relationship_rows(
13527 &dependents,
13528 render_width,
13529 show_cursor,
13530 &mut dep_index,
13531 ));
13532 }
13533
13534 lines.push(String::new());
13535 lines.push("GRAPH METRICS".to_string());
13536 lines.push("Importance:".to_string());
13537 lines.push(format!(
13538 " Critical Path {:>8} {} #{}",
13539 depth,
13540 mini_bar(depth as f64, cp_max),
13541 cp_rank
13542 ));
13543 lines.push(format!(
13544 " PageRank {:>8.4} {} #{}",
13545 pagerank,
13546 mini_bar(pagerank, pr_max),
13547 pr_rank
13548 ));
13549 lines.push(format!(
13550 " Eigenvector {:>8.4} {} #{}",
13551 eigenvector,
13552 mini_bar(eigenvector, ev_max),
13553 ev_rank
13554 ));
13555 lines.push("Flow & Connectivity:".to_string());
13556 lines.push(format!(
13557 " Betweenness {:>8.4} {} #{}",
13558 betweenness,
13559 mini_bar(betweenness, bw_max),
13560 bw_rank
13561 ));
13562 lines.push(format!(
13563 " Hub Score {:>8.4} {} #{}",
13564 hubs,
13565 mini_bar(hubs, hub_max),
13566 hub_rank
13567 ));
13568 lines.push(format!(
13569 " Authority {:>8.4} {} #{}",
13570 authorities,
13571 mini_bar(authorities, auth_max),
13572 auth_rank
13573 ));
13574 lines.push("Connections:".to_string());
13575 lines.push(format!(
13576 " In-Degree {:>8} {}",
13577 blockers.len(),
13578 mini_bar(blockers.len() as f64, total as f64)
13579 ));
13580 lines.push(format!(
13581 " Out-Degree {:>8} {}",
13582 dependents.len(),
13583 mini_bar(dependents.len() as f64, total as f64)
13584 ));
13585 lines.push(format!(
13586 " K-core: {k_core} Slack: {slack:.4} Cut: {}",
13587 if articulation { "YES" } else { "no" }
13588 ));
13589
13590 if cycle_hits.is_empty() {
13591 lines.push(" Cycles: none".to_string());
13592 } else {
13593 lines.push(" Cycles:".to_string());
13594 lines.extend(
13595 cycle_hits
13596 .iter()
13597 .take(4)
13598 .map(|cycle| format!(" {}", cycle.join(" -> "))),
13599 );
13600 }
13601
13602 lines.push(String::new());
13603 lines.push("Top PageRank:".to_string());
13604 lines.extend(
13605 top_metric_entries(&self.analyzer.metrics.pagerank, 5)
13606 .into_iter()
13607 .map(|(id, value)| format!(" {id:<12} {value:.4}")),
13608 );
13609 lines.push(String::new());
13610 lines.push(format!(
13611 "Legend: █ relative score | #N rank of {total} issues"
13612 ));
13613 lines.push(
13614 "Nav: h/l nodes | j/k nodes or focused edges | Tab node/edge focus | Enter open details"
13615 .to_string(),
13616 );
13617
13618 lines
13619 .into_iter()
13620 .map(|line| truncate_display(&line, render_width))
13621 .collect::<Vec<_>>()
13622 .join("\n")
13623 }
13624
13625 fn history_detail_text(&self) -> String {
13626 if self.analyzer.issues.is_empty() {
13627 return "No history data available.".to_string();
13628 }
13629
13630 if matches!(self.history_view_mode, HistoryViewMode::Git) {
13631 let visible = self.history_git_visible_commit_indices();
13632 let Some(commit) = self.selected_history_git_commit() else {
13633 return "No correlated git commits available.".to_string();
13634 };
13635
13636 let cursor = self
13637 .history_event_cursor
13638 .min(visible.len().saturating_sub(1));
13639
13640 let related = self.history_git_related_beads_for_commit(&commit.sha);
13641
13642 let commit_icon = commit_type_icon(&commit.message);
13643 let initials = author_initials(&commit.author);
13644 let mut lines = vec![
13645 format!(
13646 "Commit {}/{} | confidence >= {:.0}%",
13647 cursor + 1,
13648 visible.len(),
13649 self.history_min_confidence() * 100.0
13650 ),
13651 String::new(),
13652 "COMMIT DETAILS:".to_string(),
13653 format!(" SHA: {}", commit.sha),
13654 format!(" [{initials}] {} <{}>", commit.author, commit.author_email),
13655 format!(" Date: {}", commit.timestamp),
13656 format!(" {commit_icon} {}", commit.message),
13657 ];
13658
13659 if !commit.files.is_empty() {
13660 let total_ins: i64 = commit.files.iter().map(|f| f.insertions).sum();
13661 let total_del: i64 = commit.files.iter().map(|f| f.deletions).sum();
13662 lines.push(format!(
13663 " Files: {} changed +{total_ins}/-{total_del}",
13664 commit.files.len()
13665 ));
13666 for file in commit.files.iter().take(5) {
13667 let action_icon = match file.action.as_str() {
13668 "A" => "+",
13669 "D" => "-",
13670 "R" | "R100" => ">",
13671 _ => "~",
13672 };
13673 if file.insertions > 0 || file.deletions > 0 {
13674 lines.push(format!(
13675 " {action_icon} {} +{}/-{}",
13676 file.path, file.insertions, file.deletions
13677 ));
13678 } else {
13679 lines.push(format!(" {action_icon} {}", file.path));
13680 }
13681 }
13682 if commit.files.len() > 5 {
13683 lines.push(format!(" +{} more files...", commit.files.len() - 5));
13684 }
13685 }
13686
13687 if !related.is_empty() {
13688 lines.push(String::new());
13689 lines.push("RELATED BEADS:".to_string());
13690 for bead_id in &related {
13691 let conf = self
13692 .history_git_cache
13693 .as_ref()
13694 .and_then(|c| c.commit_bead_confidence.get(&commit.sha))
13695 .and_then(|pairs| {
13696 pairs.iter().find(|(id, _)| id == bead_id).map(|(_, c)| *c)
13697 })
13698 .unwrap_or(0.0);
13699 let issue_status = self
13700 .analyzer
13701 .issues
13702 .iter()
13703 .find(|i| i.id == *bead_id)
13704 .map_or("?", |i| status_icon(&i.status));
13705 let title = self
13706 .analyzer
13707 .issues
13708 .iter()
13709 .find(|i| i.id == *bead_id)
13710 .map(|i| truncate_str(&i.title, 30))
13711 .unwrap_or_default();
13712 lines.push(format!(
13713 " [{issue_status}] {bead_id} ({:.0}%) {}",
13714 conf * 100.0,
13715 title
13716 ));
13717 }
13718 }
13719
13720 if let Some(bead_commit) = self.selected_history_git_bead_commit() {
13721 lines.push(String::new());
13722 lines.push(format!(
13723 "SELECTED BEAD CHANGE ({}):",
13724 self.selected_history_git_related_bead_id()
13725 .unwrap_or_default()
13726 ));
13727 if bead_commit.field_changes.is_empty() {
13728 lines.push(" (no field-level bead changes detected)".to_string());
13729 } else {
13730 let fields = bead_commit
13731 .field_changes
13732 .iter()
13733 .map(|change| change.field.as_str())
13734 .collect::<Vec<_>>();
13735 lines.push(format!(" Fields: {}", fields.join(", ")));
13736 }
13737 if !bead_commit.bead_diff_lines.is_empty() {
13738 lines.push(" Diff:".to_string());
13739 for line in bead_commit.bead_diff_lines.iter().take(8) {
13740 lines.push(format!(" {line}"));
13741 }
13742 if bead_commit.bead_diff_lines.len() > 8 {
13743 lines.push(format!(
13744 " +{} more diff lines...",
13745 bead_commit.bead_diff_lines.len() - 8
13746 ));
13747 }
13748 }
13749 }
13750
13751 if self.history_show_file_tree {
13753 lines.push(String::new());
13754 lines.push(self.file_tree_panel_text());
13755 }
13756
13757 let action_line = if self.history_selected_commit_url().is_some() {
13758 "y: copy SHA | o: open in browser | f: file tree"
13759 } else {
13760 "y: copy SHA | f: file tree"
13761 };
13762 lines.push(String::new());
13763 lines.push(
13764 "Enter: jump to related bead | J/K: cycle related beads | diff follows cursor"
13765 .to_string(),
13766 );
13767 lines.push("v: switch to bead timeline | c: cycle confidence".to_string());
13768 lines.push(action_line.to_string());
13769 if !self.history_status_msg.is_empty() {
13770 lines.push(String::new());
13771 lines.push(self.history_status_msg.clone());
13772 }
13773 return lines.join("\n");
13774 }
13775
13776 let Some(issue) = self.selected_issue() else {
13777 return self.no_filtered_issues_text("history mode");
13778 };
13779 let selected_history = self.analyzer.history(Some(&issue.id), 1).into_iter().next();
13780 let compat_history = self
13781 .history_git_cache
13782 .as_ref()
13783 .and_then(|cache| cache.histories.get(&issue.id));
13784
13785 let all_histories = self.analyzer.history(None, 0);
13786 let closed_histories = all_histories
13787 .iter()
13788 .filter(|history| {
13789 history
13790 .events
13791 .iter()
13792 .any(|event| event.kind.eq_ignore_ascii_case("closed"))
13793 })
13794 .count();
13795
13796 let mut lines = vec![
13797 format!(
13798 "History Summary: beads={} closed-like={} selected={}",
13799 all_histories.len(),
13800 closed_histories,
13801 issue.id
13802 ),
13803 String::new(),
13804 format!("Issue: {} ({})", issue.id, issue.title),
13805 format!("Status: {}", issue.status),
13806 format!(
13807 "Min confidence filter: >= {:.0}%",
13808 self.history_min_confidence() * 100.0
13809 ),
13810 format!(
13811 "Created/Updated/Closed: {} / {} / {}",
13812 format_compact_timestamp(issue.created_at),
13813 format_compact_timestamp(issue.updated_at),
13814 format_compact_timestamp(issue.closed_at)
13815 ),
13816 ];
13817
13818 if let (Some(created), Some(closed)) = (issue.created_at, issue.closed_at) {
13819 let duration = closed - created;
13820 lines.push(format!(
13821 "Create->Close cycle time: {}d {}h",
13822 duration.num_days(),
13823 duration.num_hours() - duration.num_days() * 24
13824 ));
13825 }
13826
13827 if let Some(compat_history) = compat_history {
13829 push_text_section(
13830 &mut lines,
13831 "Timeline",
13832 &self.history_compact_timeline_text(compat_history, 56),
13833 );
13834
13835 let ms = &compat_history.milestones;
13836 let has_milestones = ms.created.is_some()
13837 || ms.claimed.is_some()
13838 || ms.closed.is_some()
13839 || ms.reopened.is_some();
13840 if has_milestones {
13841 lines.push(String::new());
13842 lines.push("Milestones:".to_string());
13843 if let Some(ref event) = ms.created {
13844 lines.push(format!(
13845 " Created: {} by {}",
13846 event.timestamp, event.author
13847 ));
13848 }
13849 if let Some(ref event) = ms.claimed {
13850 lines.push(format!(
13851 " Claimed: {} by {}",
13852 event.timestamp, event.author
13853 ));
13854 }
13855 if let Some(ref event) = ms.closed {
13856 lines.push(format!(
13857 " Closed: {} by {}",
13858 event.timestamp, event.author
13859 ));
13860 }
13861 if let Some(ref event) = ms.reopened {
13862 lines.push(format!(
13863 " Reopened: {} by {}",
13864 event.timestamp, event.author
13865 ));
13866 }
13867 }
13868
13869 if !compat_history.last_author.is_empty() {
13870 lines.push(format!("Last author: {}", compat_history.last_author));
13871 }
13872 }
13873
13874 lines.push(String::new());
13875 if let Some(compat_history) = compat_history {
13876 lines.extend(history_legacy_lifecycle_lines(compat_history, 5));
13877 } else if let Some(history) = selected_history.as_ref() {
13878 lines.push("LIFECYCLE:".to_string());
13879 if history.events.is_empty() {
13880 lines.push(" (no events)".to_string());
13881 } else {
13882 let event_count = history.events.len();
13883 for (idx, event) in history.events.iter().enumerate() {
13884 let ts = event
13885 .timestamp
13886 .map(|dt| dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
13887 .unwrap_or_else(|| "n/a".to_string());
13888 let icon = lifecycle_icon(&event.kind);
13889 let connector = if idx + 1 < event_count {
13890 "\u{2502}"
13891 } else {
13892 "\u{2514}"
13893 };
13894 lines.push(format!(
13895 " {connector} {icon} {:<10} {ts} {}",
13896 event.kind, event.details
13897 ));
13898 }
13899 }
13900 } else {
13901 lines.push("LIFECYCLE:".to_string());
13902 lines.push(" (history unavailable for selected issue)".to_string());
13903 }
13904
13905 if let Some(commit) = self.selected_history_bead_commit() {
13906 let total = self.history_filtered_bead_commits(&issue.id).len();
13907 let slot = self.history_bead_commit_cursor.min(total.saturating_sub(1));
13908
13909 lines.push(String::new());
13910 lines.push(format!(
13911 "COMMIT DETAILS ({}/{}):",
13912 slot.saturating_add(1),
13913 total
13914 ));
13915 let commit_icon = commit_type_icon(&commit.message);
13916 lines.push(format!(
13917 " {commit_icon} {} {}",
13918 commit.short_sha, commit.timestamp
13919 ));
13920 let initials = author_initials(&commit.author);
13921 lines.push(format!(
13922 " [{initials}] {} <{}>",
13923 commit.author, commit.author_email
13924 ));
13925 lines.push(format!(" {}", commit.message));
13926 lines.push(format!(
13927 " {:.0}% confidence ({})",
13928 commit.confidence * 100.0,
13929 commit.method
13930 ));
13931 if !commit.reason.is_empty() {
13932 lines.push(format!(" Reason: {}", commit.reason));
13933 }
13934 if !commit.files.is_empty() {
13935 let total_ins: i64 = commit.files.iter().map(|f| f.insertions).sum();
13936 let total_del: i64 = commit.files.iter().map(|f| f.deletions).sum();
13937 lines.push(format!(
13938 " {} file(s) +{total_ins}/-{total_del}",
13939 commit.files.len()
13940 ));
13941 for file in commit.files.iter().take(5) {
13942 let action_icon = match file.action.as_str() {
13943 "A" => "+",
13944 "D" => "-",
13945 "R" | "R100" => ">",
13946 _ => "~",
13947 };
13948 if file.insertions > 0 || file.deletions > 0 {
13949 lines.push(format!(
13950 " {action_icon} {} +{}/-{}",
13951 file.path, file.insertions, file.deletions
13952 ));
13953 } else {
13954 lines.push(format!(" {action_icon} {}", file.path));
13955 }
13956 }
13957 if commit.files.len() > 5 {
13958 lines.push(format!(" +{} more files...", commit.files.len() - 5));
13959 }
13960 }
13961 if !commit.field_changes.is_empty() {
13962 let fields = commit
13963 .field_changes
13964 .iter()
13965 .map(|change| change.field.as_str())
13966 .collect::<Vec<_>>();
13967 lines.push(format!(" Fields changed: {}", fields.join(", ")));
13968 }
13969 if !commit.bead_diff_lines.is_empty() {
13970 lines.push(" Bead diff:".to_string());
13971 for line in commit.bead_diff_lines.iter().take(8) {
13972 lines.push(format!(" {line}"));
13973 }
13974 if commit.bead_diff_lines.len() > 8 {
13975 lines.push(format!(
13976 " +{} more diff lines...",
13977 commit.bead_diff_lines.len() - 8
13978 ));
13979 }
13980 }
13981 }
13982
13983 let action_line = if self.history_selected_commit_url().is_some() {
13984 "y: copy bead ID | o: open commit | f: file tree"
13985 } else {
13986 "y: copy bead ID | f: file tree"
13987 };
13988 lines.push(String::new());
13989 lines.push(
13990 "Enter: backtrace selected commit | v: switch to git timeline | J/K: cycle commits"
13991 .to_string(),
13992 );
13993 lines.push(action_line.to_string());
13994 if !self.history_status_msg.is_empty() {
13995 lines.push(String::new());
13996 lines.push(self.history_status_msg.clone());
13997 }
13998
13999 lines.join("\n")
14000 }
14001
14002 fn history_detail_render_text(&self) -> RichText {
14003 let mut text = RichText::raw(self.history_detail_text());
14004 if let Some(url) = self.history_selected_commit_url() {
14005 text.push_line(RichLine::raw(""));
14006 text.push_line(RichLine::from_spans([
14007 RichSpan::raw("Browser Link: "),
14008 RichSpan::styled(
14009 "open selected commit (o open, right-click copy link)",
14010 tokens::panel_title_focused(),
14011 )
14012 .link(url),
14013 ]));
14014 }
14015 text
14016 }
14017
14018 fn file_tree_panel_text(&self) -> String {
14020 let flat = self.history_flat_file_list();
14021 if flat.is_empty() {
14022 return "No file data available.\n(git history may not be loaded)".to_string();
14023 }
14024
14025 let mut out = Vec::new();
14026 out.push(format!("File Tree ({} entries) | Esc close", flat.len()));
14027 if let Some(ref filter) = self.history_file_tree_filter {
14028 out.push(format!("Filter: {filter}"));
14029 }
14030 out.push(String::new());
14031
14032 for (idx, entry) in flat.iter().enumerate() {
14033 let marker = if self.history_file_tree_focus && idx == self.history_file_tree_cursor {
14034 '>'
14035 } else {
14036 ' '
14037 };
14038 let indent = " ".repeat(entry.level);
14039 let icon = if entry.is_dir { "/" } else { "" };
14040 out.push(format!(
14041 "{marker} {indent}{}{icon} ({})",
14042 entry.name, entry.change_count
14043 ));
14044 }
14045
14046 out.join("\n")
14047 }
14048}
14049
14050#[must_use]
14051fn truncate_str(value: &str, max_len: usize) -> String {
14052 truncate_display(value, max_len)
14053}
14054
14055fn metric_rank(metrics: &std::collections::HashMap<String, f64>, target_id: &str) -> usize {
14056 let target_value = metrics.get(target_id).copied().unwrap_or_default();
14057 metrics
14058 .values()
14059 .filter(|&&value| value > target_value)
14060 .count()
14061 + 1
14062}
14063
14064fn max_metric_value(metrics: &std::collections::HashMap<String, f64>) -> f64 {
14065 metrics.values().copied().fold(0.0_f64, f64::max).max(1e-9)
14066}
14067
14068fn run_command_with_stdin(program: &str, args: &[&str], input: &str) -> bool {
14069 let Ok(mut child) = std::process::Command::new(program)
14070 .args(args)
14071 .stdin(std::process::Stdio::piped())
14072 .stdout(std::process::Stdio::null())
14073 .stderr(std::process::Stdio::null())
14074 .spawn()
14075 else {
14076 return false;
14077 };
14078
14079 if let Some(mut stdin) = child.stdin.take()
14080 && stdin.write_all(input.as_bytes()).is_err()
14081 {
14082 let _ = child.kill();
14083 let _ = child.wait();
14084 return false;
14085 }
14086
14087 child.wait().is_ok_and(|status| status.success())
14088}
14089
14090fn run_command(program: &str, args: &[&str]) -> bool {
14091 std::process::Command::new(program)
14092 .args(args)
14093 .stdin(std::process::Stdio::null())
14094 .stdout(std::process::Stdio::null())
14095 .stderr(std::process::Stdio::null())
14096 .status()
14097 .is_ok_and(|status| status.success())
14098}
14099
14100fn copy_text_to_clipboard(text: &str) -> bool {
14101 run_command_with_stdin("wl-copy", &[], text)
14102 || run_command_with_stdin("xclip", &["-selection", "clipboard"], text)
14103 || run_command_with_stdin("xsel", &["--clipboard", "--input"], text)
14104 || run_command_with_stdin("pbcopy", &[], text)
14105 || run_command_with_stdin("clip.exe", &[], text)
14106}
14107
14108fn open_url_in_browser(url: &str) -> bool {
14109 if cfg!(target_os = "windows") {
14110 run_command("cmd", &["/C", "start", "", url])
14111 } else if cfg!(target_os = "macos") {
14112 run_command("open", &[url])
14113 } else {
14114 run_command("xdg-open", &[url])
14115 || run_command("open", &[url])
14116 || run_command("gio", &["open", url])
14117 }
14118}
14119
14120fn mini_bar(value: f64, max: f64) -> String {
14121 let width: usize = 6;
14122 let ratio = if max > 0.0 { value / max } else { 0.0 };
14123 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
14124 let filled = ratio
14125 .mul_add(width as f64, 0.0)
14126 .round()
14127 .clamp(0.0, width as f64) as usize;
14128 let empty = width - filled;
14129 std::iter::repeat_n('\u{2588}', filled)
14130 .chain(std::iter::repeat_n('\u{2591}', empty))
14131 .collect()
14132}
14133
14134fn status_icon(status: &str) -> &'static str {
14135 match status.to_ascii_lowercase().as_str() {
14136 "open" => "o",
14137 "in_progress" => "*",
14138 "blocked" => "!",
14139 "closed" => "x",
14140 "deferred" | "draft" => "~",
14141 "review" => "r",
14142 "pinned" => "^",
14143 "hooked" => "&",
14144 "tombstone" => "#",
14145 _ => "?",
14146 }
14147}
14148
14149fn type_icon(issue_type: &str) -> &'static str {
14150 match issue_type.to_ascii_lowercase().as_str() {
14151 "bug" => "B",
14152 "feature" => "F",
14153 "task" => "T",
14154 "epic" => "E",
14155 "question" => "Q",
14156 "docs" => "D",
14157 "refactor" => "R",
14158 _ => "-",
14159 }
14160}
14161
14162fn lifecycle_icon(kind: &str) -> &'static str {
14163 match kind.to_ascii_lowercase().as_str() {
14164 "created" => "+",
14165 "claimed" | "assigned" => "@",
14166 "closed" => "x",
14167 "reopened" => "~",
14168 "modified" | "updated" => ".",
14169 _ => "-",
14170 }
14171}
14172
14173fn commit_type_icon(message: &str) -> &'static str {
14174 let lower = message.to_ascii_lowercase();
14175 if lower.starts_with("feat") {
14176 "F"
14177 } else if lower.starts_with("fix") {
14178 "B"
14179 } else if lower.starts_with("docs") {
14180 "D"
14181 } else if lower.starts_with("refactor") {
14182 "R"
14183 } else if lower.starts_with("test") {
14184 "T"
14185 } else if lower.starts_with("chore") {
14186 "C"
14187 } else if lower.starts_with("perf") {
14188 "P"
14189 } else if lower.starts_with("ci") {
14190 "I"
14191 } else if lower.starts_with("build") {
14192 "K"
14193 } else if lower.starts_with("style") {
14194 "S"
14195 } else if lower.starts_with("merge") || lower.starts_with("Merge") {
14196 "M"
14197 } else if lower.starts_with("revert") {
14198 "<"
14199 } else {
14200 "*"
14201 }
14202}
14203
14204fn author_initials(name: &str) -> String {
14205 name.split_whitespace()
14206 .filter_map(|word| word.chars().next())
14207 .take(2)
14208 .flat_map(char::to_uppercase)
14209 .collect::<String>()
14210}
14211
14212fn display_or_fallback(value: &str, fallback: &str) -> String {
14213 let trimmed = value.trim();
14214 if trimmed.is_empty() {
14215 fallback.to_string()
14216 } else {
14217 trimmed.to_string()
14218 }
14219}
14220
14221fn format_compact_timestamp(dt: Option<DateTime<Utc>>) -> String {
14222 dt.map_or_else(|| "n/a".to_string(), |ts| ts.format("%Y-%m-%d").to_string())
14223}
14224
14225fn compact_history_duration_label(raw: &str) -> String {
14226 raw.split_whitespace()
14227 .find_map(|token| {
14228 let digits = token
14229 .chars()
14230 .take_while(|ch| ch.is_ascii_digit() || *ch == '-')
14231 .collect::<String>();
14232 digits
14233 .parse::<i64>()
14234 .ok()
14235 .filter(|value| *value > 0)
14236 .map(|_| token.to_string())
14237 })
14238 .or_else(|| raw.split_whitespace().next().map(str::to_string))
14239 .unwrap_or_else(|| raw.to_string())
14240}
14241
14242fn compact_history_month_day(timestamp: &str) -> Option<String> {
14243 DateTime::parse_from_rfc3339(timestamp)
14244 .ok()
14245 .map(|dt| dt.format("%b %-d").to_string())
14246}
14247
14248#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
14249enum LegacyTimelineEntryType {
14250 Event,
14251 Commit,
14252}
14253
14254#[derive(Debug, Clone)]
14255struct LegacyTimelineEntry {
14256 timestamp: String,
14257 parsed_ts: Option<DateTime<Utc>>,
14258 entry_type: LegacyTimelineEntryType,
14259 label: String,
14260 detail: String,
14261 confidence: Option<f64>,
14262}
14263
14264fn legacy_history_author_initials(name: &str) -> String {
14265 let parts = name.split_whitespace().collect::<Vec<_>>();
14266 match parts.as_slice() {
14267 [] => "??".to_string(),
14268 [single] => {
14269 let mut chars = single.chars();
14270 match (chars.next(), chars.next()) {
14271 (Some(first), Some(second)) => [first, second]
14272 .into_iter()
14273 .flat_map(char::to_uppercase)
14274 .collect(),
14275 (Some(first), None) => first.to_uppercase().collect(),
14276 (None, _) => "??".to_string(),
14277 }
14278 }
14279 [first, .., last] => [first.chars().next(), last.chars().next()]
14280 .into_iter()
14281 .flatten()
14282 .flat_map(char::to_uppercase)
14283 .collect(),
14284 }
14285}
14286
14287fn legacy_history_relative_time(timestamp: &str) -> Option<String> {
14288 let ts = DateTime::parse_from_rfc3339(timestamp).ok()?;
14289 let diff = Utc::now().signed_duration_since(ts.with_timezone(&Utc));
14290 if diff < chrono::Duration::zero() {
14291 return Some("in future".to_string());
14292 }
14293 if diff < chrono::Duration::minutes(1) {
14294 return Some("just now".to_string());
14295 }
14296 if diff < chrono::Duration::hours(1) {
14297 return Some(format!("{}m ago", diff.num_minutes()));
14298 }
14299 if diff < chrono::Duration::days(1) {
14300 return Some(format!("{}h ago", diff.num_hours()));
14301 }
14302 if diff < chrono::Duration::days(7) {
14303 return Some(format!("{}d ago", diff.num_days()));
14304 }
14305 if diff < chrono::Duration::days(30) {
14306 return Some(format!("{}w ago", diff.num_weeks()));
14307 }
14308 if diff < chrono::Duration::days(365) {
14309 return Some(format!("{}mo ago", diff.num_days() / 30));
14310 }
14311 Some(format!("{}y ago", diff.num_days() / 365))
14312}
14313
14314fn legacy_history_lifecycle_icon(kind: &str) -> &'static str {
14315 match kind.to_ascii_lowercase().as_str() {
14316 "created" => "🆕",
14317 "claimed" | "assigned" => "👤",
14318 "closed" => "✓",
14319 "reopened" => "↺",
14320 "modified" | "updated" => "✎",
14321 _ => "•",
14322 }
14323}
14324
14325fn history_legacy_lifecycle_lines(
14326 compat_history: &HistoryBeadCompat,
14327 max_lines: usize,
14328) -> Vec<String> {
14329 let mut events = if compat_history.events.is_empty() {
14330 let mut fallback = Vec::new();
14331 if let Some(event) = compat_history.milestones.created.clone() {
14332 fallback.push(event);
14333 }
14334 if let Some(event) = compat_history.milestones.claimed.clone() {
14335 fallback.push(event);
14336 }
14337 if let Some(event) = compat_history.milestones.closed.clone() {
14338 fallback.push(event);
14339 }
14340 if let Some(event) = compat_history.milestones.reopened.clone() {
14341 fallback.push(event);
14342 }
14343 fallback
14344 } else {
14345 compat_history.events.clone()
14346 };
14347
14348 if events.is_empty() {
14349 return vec!["LIFECYCLE:".to_string(), " (no events)".to_string()];
14350 }
14351
14352 events.sort_by(|left, right| {
14353 left.timestamp
14354 .cmp(&right.timestamp)
14355 .then_with(|| left.event_type.cmp(&right.event_type))
14356 });
14357
14358 let mut lines = vec![format!("LIFECYCLE ({})", events.len())];
14359 let mut available_events = max_lines.saturating_sub(1);
14360 let needs_more_line = events.len() > available_events;
14361 if needs_more_line {
14362 available_events = available_events.saturating_sub(1);
14363 }
14364
14365 let mut displayed = 0usize;
14366 for index in (0..events.len()).rev() {
14367 if displayed >= available_events {
14368 break;
14369 }
14370 let event = &events[index];
14371 let connector = if index == 0 { "└" } else { "│" };
14372 let relative =
14373 legacy_history_relative_time(&event.timestamp).unwrap_or_else(|| "n/a".to_string());
14374 let initials = legacy_history_author_initials(&event.author);
14375 lines.push(format!(
14376 " {connector} {} {:<7} {initials}",
14377 legacy_history_lifecycle_icon(&event.event_type),
14378 truncate_display(&relative, 7),
14379 ));
14380 displayed += 1;
14381 }
14382
14383 if needs_more_line {
14384 lines.push(format!(" +{} more", events.len() - displayed));
14385 }
14386
14387 lines
14388}
14389
14390fn legacy_timeline_timestamp(timestamp: &str) -> Option<String> {
14391 let ts = DateTime::parse_from_rfc3339(timestamp)
14392 .ok()?
14393 .with_timezone(&Utc);
14394 let diff = Utc::now().signed_duration_since(ts);
14395 Some(if diff < chrono::Duration::days(1) {
14396 ts.format("%-I:%M %p").to_string()
14397 } else if diff < chrono::Duration::days(7) {
14398 ts.format("%a %-I%p").to_string()
14399 } else if diff < chrono::Duration::days(365) {
14400 ts.format("%b %-d").to_string()
14401 } else {
14402 ts.format("%b '%y").to_string()
14403 })
14404}
14405
14406fn build_legacy_timeline_entries(
14407 compat_history: &HistoryBeadCompat,
14408 commits: &[&HistoryCommitCompat],
14409) -> Vec<LegacyTimelineEntry> {
14410 let mut entries = Vec::new();
14411
14412 if let Some(event) = compat_history.milestones.created.as_ref() {
14413 entries.push(LegacyTimelineEntry {
14414 timestamp: event.timestamp.clone(),
14415 parsed_ts: DateTime::parse_from_rfc3339(&event.timestamp)
14416 .ok()
14417 .map(|dt| dt.with_timezone(&Utc)),
14418 entry_type: LegacyTimelineEntryType::Event,
14419 label: "○ Created".to_string(),
14420 detail: compat_history.title.clone(),
14421 confidence: None,
14422 });
14423 }
14424 if let Some(event) = compat_history.milestones.claimed.as_ref() {
14425 entries.push(LegacyTimelineEntry {
14426 timestamp: event.timestamp.clone(),
14427 parsed_ts: DateTime::parse_from_rfc3339(&event.timestamp)
14428 .ok()
14429 .map(|dt| dt.with_timezone(&Utc)),
14430 entry_type: LegacyTimelineEntryType::Event,
14431 label: "● Claimed".to_string(),
14432 detail: format!("by {}", event.author),
14433 confidence: None,
14434 });
14435 }
14436 if let Some(event) = compat_history.milestones.reopened.as_ref() {
14437 entries.push(LegacyTimelineEntry {
14438 timestamp: event.timestamp.clone(),
14439 parsed_ts: DateTime::parse_from_rfc3339(&event.timestamp)
14440 .ok()
14441 .map(|dt| dt.with_timezone(&Utc)),
14442 entry_type: LegacyTimelineEntryType::Event,
14443 label: "↻ Reopened".to_string(),
14444 detail: String::new(),
14445 confidence: None,
14446 });
14447 }
14448 if let Some(event) = compat_history.milestones.closed.as_ref() {
14449 entries.push(LegacyTimelineEntry {
14450 timestamp: event.timestamp.clone(),
14451 parsed_ts: DateTime::parse_from_rfc3339(&event.timestamp)
14452 .ok()
14453 .map(|dt| dt.with_timezone(&Utc)),
14454 entry_type: LegacyTimelineEntryType::Event,
14455 label: "✓ Closed".to_string(),
14456 detail: String::new(),
14457 confidence: None,
14458 });
14459 }
14460
14461 for commit in commits {
14462 entries.push(LegacyTimelineEntry {
14463 timestamp: commit.timestamp.clone(),
14464 parsed_ts: DateTime::parse_from_rfc3339(&commit.timestamp)
14465 .ok()
14466 .map(|dt| dt.with_timezone(&Utc)),
14467 entry_type: LegacyTimelineEntryType::Commit,
14468 label: commit.short_sha.clone(),
14469 detail: commit.message.clone(),
14470 confidence: Some(commit.confidence),
14471 });
14472 }
14473
14474 entries.sort_by(|left, right| {
14475 left.parsed_ts
14476 .cmp(&right.parsed_ts)
14477 .then_with(|| left.entry_type.cmp(&right.entry_type))
14478 .then_with(|| left.label.cmp(&right.label))
14479 });
14480 entries
14481}
14482
14483fn render_legacy_timeline_lines(
14484 compat_history: &HistoryBeadCompat,
14485 commits: &[&HistoryCommitCompat],
14486 width: usize,
14487 max_visible: usize,
14488) -> Vec<String> {
14489 let entries = build_legacy_timeline_entries(compat_history, commits);
14490 if entries.is_empty() {
14491 return vec!["No events recorded".to_string()];
14492 }
14493
14494 let shown = entries.iter().take(max_visible).collect::<Vec<_>>();
14495 let mut lines = Vec::new();
14496 let text_width = width.saturating_sub(12).max(8);
14497
14498 for entry in &shown {
14499 let ts = legacy_timeline_timestamp(&entry.timestamp).unwrap_or_else(|| "n/a".to_string());
14500 let ts = format!("{ts:>8}");
14501 match entry.entry_type {
14502 LegacyTimelineEntryType::Event => {
14503 let mut line = format!("{ts} | {}", entry.label);
14504 if !entry.detail.is_empty() {
14505 line.push(' ');
14506 line.push_str(&truncate_display(
14507 &entry.detail,
14508 text_width.saturating_sub(2),
14509 ));
14510 }
14511 lines.push(truncate_display(&line, width));
14512 }
14513 LegacyTimelineEntryType::Commit => {
14514 let confidence = entry.confidence.unwrap_or(0.0) * 100.0;
14515 lines.push(truncate_display(
14516 &format!("{ts} | ├─ {} {:.0}%", entry.label, confidence),
14517 width,
14518 ));
14519 if !entry.detail.is_empty() {
14520 lines.push(truncate_display(
14521 &format!(
14522 "{:>8} | {}",
14523 "",
14524 truncate_display(&entry.detail, text_width)
14525 ),
14526 width,
14527 ));
14528 }
14529 }
14530 }
14531 }
14532
14533 if entries.len() > shown.len() {
14534 lines.push(truncate_display(
14535 &format!("{:>8} | ↕ 1-{} of {}", "", shown.len(), entries.len()),
14536 width,
14537 ));
14538 }
14539
14540 lines
14541}
14542
14543fn join_display_values(values: &[String], limit: usize) -> String {
14544 if values.is_empty() {
14545 return "none".to_string();
14546 }
14547
14548 let mut parts = values.iter().take(limit).cloned().collect::<Vec<_>>();
14549 if values.len() > limit {
14550 parts.push(format!("+{} more", values.len() - limit));
14551 }
14552 parts.join(", ")
14553}
14554
14555fn push_text_section(lines: &mut Vec<String>, title: &str, body: &str) {
14556 let trimmed = body.trim();
14557 if trimmed.is_empty() {
14558 return;
14559 }
14560
14561 lines.push(String::new());
14562 lines.push(format!("{title}:"));
14563 for line in trimmed.lines() {
14564 let content = line.trim_end();
14565 if content.is_empty() {
14566 lines.push(" ".to_string());
14567 } else {
14568 lines.push(format!(" {content}"));
14569 }
14570 }
14571}
14572
14573fn push_comment_section(lines: &mut Vec<String>, issue: &Issue) {
14574 if issue.comments.is_empty() {
14575 return;
14576 }
14577
14578 lines.push(String::new());
14579 lines.push(format!("Recent Comments ({}):", issue.comments.len()));
14580 for comment in issue.comments.iter().rev().take(3) {
14581 lines.push(format!(
14582 " - {} @ {}",
14583 display_or_fallback(&comment.author, "unknown"),
14584 format_compact_timestamp(comment.created_at)
14585 ));
14586 for line in comment.text.lines().take(3) {
14587 lines.push(format!(" {}", line.trim_end()));
14588 }
14589 if comment.text.lines().count() > 3 {
14590 lines.push(" ...".to_string());
14591 }
14592 }
14593}
14594
14595fn push_history_section(lines: &mut Vec<String>, history: Option<&IssueHistory>) {
14596 let Some(history) = history else {
14597 return;
14598 };
14599 if history.events.is_empty() {
14600 return;
14601 }
14602
14603 lines.push(String::new());
14604 lines.push(format!(
14605 "History Summary ({} events):",
14606 history.events.len()
14607 ));
14608 let start = history.events.len().saturating_sub(4);
14609 for event in history.events.iter().skip(start) {
14610 lines.push(format!(
14611 " {} {} {}",
14612 lifecycle_icon(&event.kind),
14613 format_compact_timestamp(event.timestamp),
14614 event.details
14615 ));
14616 }
14617}
14618
14619fn top_metric_entries(
14620 metrics: &std::collections::HashMap<String, f64>,
14621 limit: usize,
14622) -> Vec<(String, f64)> {
14623 let mut entries = metrics
14624 .iter()
14625 .map(|(id, value)| (id.clone(), *value))
14626 .collect::<Vec<_>>();
14627 entries.sort_by(|left, right| {
14628 right
14629 .1
14630 .total_cmp(&left.1)
14631 .then_with(|| left.0.cmp(&right.0))
14632 });
14633 entries.truncate(limit);
14634 entries
14635}
14636
14637fn truncate_display(value: &str, max_len: usize) -> String {
14638 if max_len == 0 {
14639 return String::new();
14640 }
14641
14642 if display_width(value) <= max_len {
14643 return value.to_string();
14644 }
14645 if max_len == 1 {
14646 return truncate_to_width(value, max_len);
14647 }
14648
14649 truncate_with_ellipsis(value, max_len, "…")
14650}
14651
14652fn tone_for_status(status: &str) -> SemanticTone {
14653 if status.eq_ignore_ascii_case("open") || status.eq_ignore_ascii_case("review") {
14654 SemanticTone::Accent
14655 } else if status.eq_ignore_ascii_case("in_progress") || status.eq_ignore_ascii_case("hooked") {
14656 SemanticTone::Warning
14657 } else if status.eq_ignore_ascii_case("blocked") || status.eq_ignore_ascii_case("tombstone") {
14658 SemanticTone::Danger
14659 } else if status.eq_ignore_ascii_case("closed") {
14660 SemanticTone::Success
14661 } else {
14662 SemanticTone::Muted
14663 }
14664}
14665
14666fn tone_for_state(state: &str) -> SemanticTone {
14667 if state.eq_ignore_ascii_case("ready") {
14668 SemanticTone::Success
14669 } else if state.eq_ignore_ascii_case("blocked") {
14670 SemanticTone::Danger
14671 } else if state.eq_ignore_ascii_case("closed") {
14672 SemanticTone::Muted
14673 } else {
14674 SemanticTone::Accent
14675 }
14676}
14677
14678fn tone_for_priority(priority: &str) -> SemanticTone {
14679 match priority.trim_start_matches(['p', 'P']) {
14680 "0" => SemanticTone::Danger,
14681 "1" => SemanticTone::Warning,
14682 "2" => SemanticTone::Accent,
14683 "3" | "4" => SemanticTone::Muted,
14684 _ => SemanticTone::Neutral,
14685 }
14686}
14687
14688fn summary_line_from_pairs(
14689 line: &str,
14690 tone_for_key: impl Fn(&str, &str) -> SemanticTone,
14691) -> Option<RichLine> {
14692 let mut out = RichLine::new();
14693 let mut wrote_any = false;
14694 for part in line.split(" | ") {
14695 let (label, value) = part.split_once(": ")?;
14696 if wrote_any {
14697 out.push_span(RichSpan::styled(" | ", tokens::dim()));
14698 }
14699 out.push_span(RichSpan::styled(format!("{label}:"), tokens::dim()));
14700 out.push_span(RichSpan::raw(" "));
14701 push_chip(&mut out, value, tone_for_key(label, value));
14702 wrote_any = true;
14703 }
14704 wrote_any.then_some(out)
14705}
14706
14707fn styled_detail_summary_line(line: &str) -> Option<RichLine> {
14708 if line.ends_with(':') && !line.starts_with(" ") {
14709 return Some(RichLine::from_spans([RichSpan::styled(
14710 line,
14711 tokens::chip_style(SemanticTone::Accent),
14712 )]));
14713 }
14714
14715 if line.starts_with("Status: ") {
14716 return summary_line_from_pairs(line, |label, value| match label {
14717 "Status" => tone_for_status(value),
14718 "Priority" => tone_for_priority(value),
14719 "State" => tone_for_state(value),
14720 "Type" => SemanticTone::Neutral,
14721 _ => SemanticTone::Muted,
14722 });
14723 }
14724
14725 None
14726}
14727
14728fn command_hint_width(hint: CommandHint<'_>) -> usize {
14729 display_width(hint.key) + 1 + display_width(hint.desc)
14730}
14731
14732fn command_hint_line(hints: &[CommandHint<'_>]) -> RichLine {
14733 let mut line = RichLine::new();
14734 for (index, hint) in hints.iter().enumerate() {
14735 if index > 0 {
14736 line.push_span(RichSpan::styled(" │ ", tokens::footer_sep()));
14737 }
14738 line.push_span(RichSpan::styled(hint.key, tokens::footer_key()));
14739 line.push_span(RichSpan::raw(" "));
14740 line.push_span(RichSpan::styled(hint.desc, tokens::footer_hint()));
14741 }
14742 line
14743}
14744
14745fn wrap_command_hints(hints: &[CommandHint<'_>], width: usize) -> RichText {
14746 if hints.is_empty() || width == 0 {
14747 return RichText::new();
14748 }
14749
14750 let mut lines = Vec::new();
14751 let mut line_start = 0usize;
14752 let mut line_width = 0usize;
14753
14754 for (index, hint) in hints.iter().copied().enumerate() {
14755 let hint_width = command_hint_width(hint);
14756 let separator_width = usize::from(index > line_start) * 3;
14757 if index > line_start && line_width + separator_width + hint_width > width {
14758 lines.push(command_hint_line(&hints[line_start..index]));
14759 line_start = index;
14760 line_width = hint_width;
14761 } else {
14762 line_width += separator_width + hint_width;
14763 }
14764 }
14765
14766 if line_start < hints.len() {
14767 lines.push(command_hint_line(&hints[line_start..]));
14768 }
14769
14770 RichText::from_lines(lines)
14771}
14772
14773fn styled_mode_footer(prefix: &str, hints: &[CommandHint<'_>], width: u16) -> RichText {
14775 let mut first_line = RichLine::new();
14776 first_line.push_span(RichSpan::styled(prefix, tokens::footer_dim()));
14777 first_line.push_span(RichSpan::styled(" │ ", tokens::footer_sep()));
14778
14779 let avail = width.saturating_sub(1) as usize;
14781 let prefix_width = display_width(prefix) + 3; let hint_text = wrap_command_hints(hints, avail.saturating_sub(prefix_width));
14783
14784 if let Some(first_hint_line) = hint_text.lines().first() {
14785 for span in first_hint_line.spans() {
14786 first_line.push_span(span.clone());
14787 }
14788 }
14789
14790 let mut lines = vec![first_line];
14791 for extra in hint_text.lines().iter().skip(1) {
14792 lines.push(extra.clone());
14793 }
14794 RichText::from_lines(lines)
14795}
14796
14797fn fit_display(value: &str, width: usize) -> String {
14798 let mut out = truncate_display(value, width);
14799 let visible = display_width(&out);
14800 if visible < width {
14801 out.push_str(&" ".repeat(width - visible));
14802 }
14803 out
14804}
14805
14806fn center_display(value: &str, width: usize) -> String {
14807 let text = truncate_display(value, width);
14808 let visible = display_width(&text);
14809 if visible >= width {
14810 return text;
14811 }
14812
14813 let left = (width - visible) / 2;
14814 let right = width - visible - left;
14815 format!("{}{}{}", " ".repeat(left), text, " ".repeat(right))
14816}
14817
14818fn center_box_rows(boxes: &[Vec<String>], width: usize) -> Vec<String> {
14819 if boxes.is_empty() {
14820 return Vec::new();
14821 }
14822
14823 let height = boxes.iter().map(Vec::len).max().unwrap_or(0);
14824 (0..height)
14825 .map(|row| {
14826 let joined = boxes
14827 .iter()
14828 .map(|box_lines| {
14829 if let Some(line) = box_lines.get(row) {
14830 line.clone()
14831 } else {
14832 let fallback_width =
14833 box_lines.first().map_or(0, |line| display_width(line));
14834 " ".repeat(fallback_width)
14835 }
14836 })
14837 .collect::<Vec<_>>()
14838 .join(" ");
14839 center_display(&joined, width)
14840 })
14841 .collect()
14842}
14843
14844fn cmp_opt_datetime(
14845 left: Option<DateTime<Utc>>,
14846 right: Option<DateTime<Utc>>,
14847 descending: bool,
14848) -> std::cmp::Ordering {
14849 match (left, right) {
14850 (Some(left), Some(right)) => {
14851 if descending {
14852 right.cmp(&left)
14853 } else {
14854 left.cmp(&right)
14855 }
14856 }
14857 (Some(_), None) => std::cmp::Ordering::Less,
14858 (None, Some(_)) => std::cmp::Ordering::Greater,
14859 (None, None) => std::cmp::Ordering::Equal,
14860 }
14861}
14862
14863fn remote_to_commit_url(remote: &str, sha: &str) -> Option<String> {
14865 let trimmed = remote.trim();
14867 let web_base = if let Some(rest) = trimmed.strip_prefix("git@") {
14868 let rest = rest.strip_suffix(".git").unwrap_or(rest);
14870 let rest = rest.replacen(':', "/", 1);
14871 format!("https://{rest}")
14872 } else if trimmed.starts_with("https://") || trimmed.starts_with("http://") {
14873 let base = trimmed.strip_suffix(".git").unwrap_or(trimmed);
14874 base.to_string()
14875 } else {
14876 return None;
14877 };
14878
14879 Some(format!("{web_base}/commit/{sha}"))
14880}
14881
14882fn new_app(issues: Vec<Issue>, mode: ViewMode) -> BvrApp {
14883 new_app_with_background(issues, mode, None)
14884}
14885
14886fn new_app_with_background(
14887 issues: Vec<Issue>,
14888 mode: ViewMode,
14889 background_config: Option<BackgroundModeConfig>,
14890) -> BvrApp {
14891 #[cfg(not(test))]
14892 let initial_data_hash = compute_data_hash(&issues);
14893 #[cfg(not(test))]
14894 let background_runtime = background_config.map(|config| {
14895 let mut timeline = VecDeque::new();
14896 timeline.push_back(background_timeline_entry("background mode initialized"));
14897
14898 BackgroundRuntimeState {
14899 config: config.normalized(),
14900 in_flight: false,
14901 cancel_requested: Arc::new(AtomicBool::new(false)),
14902 last_data_hash: initial_data_hash,
14903 timeline,
14904 }
14905 });
14906 #[cfg(test)]
14907 let _ = background_config;
14908
14909 let repo_root = loader::get_beads_dir(None)
14910 .ok()
14911 .and_then(|beads_dir| beads_dir.parent().map(std::path::Path::to_path_buf));
14912
14913 let use_two_phase =
14914 issues.len() > crate::analysis::graph::AnalysisConfig::background_threshold();
14915 let analyzer = if use_two_phase {
14916 Analyzer::new_fast(issues)
14917 } else {
14918 Analyzer::new(issues)
14919 };
14920 #[cfg(not(test))]
14921 let slow_metrics_rx = if use_two_phase {
14922 Some(analyzer.spawn_slow_computation())
14923 } else {
14924 None
14925 };
14926 let slow_metrics_pending = use_two_phase;
14927
14928 BvrApp {
14929 analyzer,
14930 repo_root,
14931 selected: 0,
14932 list_filter: ListFilter::All,
14933 list_sort: ListSort::Default,
14934 board_grouping: BoardGrouping::Status,
14935 board_empty_visibility: EmptyLaneVisibility::Auto,
14936 mode,
14937 mode_before_history: ViewMode::Main,
14938 mode_back_stack: Vec::new(),
14939 focus: FocusPane::List,
14940 focus_before_help: FocusPane::List,
14941 show_help: false,
14942 help_scroll_offset: 0,
14943 show_quit_confirm: false,
14944 modal_overlay: None,
14945 modal_confirm_result: None,
14946 history_confidence_index: 0,
14947 history_view_mode: HistoryViewMode::Bead,
14948 history_event_cursor: 0,
14949 history_related_bead_cursor: 0,
14950 history_bead_commit_cursor: 0,
14951 history_git_cache: None,
14952 history_search_active: false,
14953 history_search_query: String::new(),
14954 history_search_match_cursor: 0,
14955 history_search_mode: HistorySearchMode::All,
14956 history_show_file_tree: false,
14957 history_file_tree_cursor: 0,
14958 history_file_tree_filter: None,
14959 history_file_tree_focus: false,
14960 history_status_msg: String::new(),
14961 board_search_active: false,
14962 board_search_query: String::new(),
14963 board_search_match_cursor: 0,
14964 board_detail_scroll_offset: 0,
14965 detail_scroll_offset: 0,
14966 main_search_active: false,
14967 main_search_query: String::new(),
14968 main_search_match_cursor: 0,
14969 list_scroll_offset: Cell::new(0),
14970 list_viewport_height: Cell::new(0),
14971 graph_search_active: false,
14972 graph_search_query: String::new(),
14973 graph_search_match_cursor: 0,
14974 insights_search_active: false,
14975 insights_search_query: String::new(),
14976 insights_search_match_cursor: 0,
14977 insights_panel: InsightsPanel::Bottlenecks,
14978 insights_heatmap: None,
14979 insights_show_explanations: true,
14980 insights_show_calc_proof: false,
14981 detail_dep_cursor: 0,
14982 actionable_plan: None,
14983 actionable_track_cursor: 0,
14984 actionable_item_cursor: 0,
14985 attention_result: None,
14986 attention_cursor: 0,
14987 tree_flat_nodes: Vec::new(),
14988 tree_cursor: 0,
14989 tree_collapsed: std::collections::HashSet::new(),
14990 tree_search_active: false,
14991 tree_search_query: String::new(),
14992 tree_search_match_cursor: 0,
14993 pending_g: false,
14994 g_pre_toggle_mode: None,
14995 pending_z: false,
14996 label_dashboard: None,
14997 label_dashboard_cursor: 0,
14998 flow_matrix: None,
14999 flow_matrix_row_cursor: 0,
15000 flow_matrix_col_cursor: 0,
15001 time_travel_ref_input: String::new(),
15002 time_travel_input_active: false,
15003 time_travel_diff: None,
15004 time_travel_category_cursor: 0,
15005 time_travel_issue_cursor: 0,
15006 time_travel_last_ref: None,
15007 sprint_data: Vec::new(),
15008 sprint_cursor: 0,
15009 sprint_issue_cursor: 0,
15010 modal_label_filter: None,
15011 modal_repo_filter: None,
15012 priority_hints_visible: false,
15013 status_msg: String::new(),
15014 slow_metrics_pending,
15015 #[cfg(not(test))]
15016 slow_metrics_rx,
15017 #[cfg(not(test))]
15018 background_runtime,
15019 #[cfg(test)]
15020 key_trace: Vec::new(),
15021 }
15022}
15023
15024pub fn run_tui(issues: Vec<Issue>) -> Result<()> {
15025 run_tui_with_background(issues, None, None, None)
15026}
15027
15028pub fn run_tui_with_background(
15029 issues: Vec<Issue>,
15030 background_config: Option<BackgroundModeConfig>,
15031 initial_view: Option<ViewMode>,
15032 initial_filter: Option<ListFilter>,
15033) -> Result<()> {
15034 let mode = initial_view.unwrap_or(ViewMode::Main);
15035 let mut model = new_app_with_background(issues, mode, background_config);
15036 if let Some(filter) = initial_filter {
15037 model.list_filter = filter;
15038 }
15039 match mode {
15044 ViewMode::Tree => model.build_tree_flat_nodes(),
15045 ViewMode::LabelDashboard => model.compute_label_dashboard(),
15046 ViewMode::FlowMatrix => model.compute_flow_matrix(),
15047 ViewMode::Sprint => model.load_sprint_data(),
15048 ViewMode::Actionable => model.compute_actionable_plan(),
15049 ViewMode::Attention => model.compute_attention(),
15050 _ => {}
15051 }
15052 App::new(model)
15053 .screen_mode(ScreenMode::AltScreen)
15054 .run()
15055 .map_err(|error| BvrError::Tui(error.to_string()))
15056}
15057
15058pub fn render_debug_view(
15062 issues: Vec<Issue>,
15063 view_name: &str,
15064 width: u16,
15065 height: u16,
15066) -> Result<String> {
15067 let (mode, kind) = parse_debug_render_target(view_name)?;
15068
15069 #[cfg(test)]
15070 set_pane_split_state(PaneSplitState::default());
15071
15072 let mut app = new_app(issues, mode);
15073 if matches!(mode, ViewMode::History) && matches!(kind, DebugRenderKind::Layout) {
15074 app.history_view_mode = HistoryViewMode::Bead;
15075 }
15076 let mut pool = ftui::GraphemePool::default();
15077 let mut frame = Frame::new(width, height, &mut pool);
15078 app.view(&mut frame);
15079 match kind {
15080 DebugRenderKind::View => Ok(buffer_to_text(&frame.buffer, &pool)),
15081 DebugRenderKind::Layout => Ok(render_layout_debug_report(&app, width, height)),
15082 DebugRenderKind::HitTest => Ok(render_hittest_debug_report(&app, width, height)),
15083 DebugRenderKind::Capture => Ok(render_capture_debug_report(
15084 &app,
15085 &buffer_to_text(&frame.buffer, &pool),
15086 width,
15087 height,
15088 )),
15089 }
15090}
15091
15092fn buffer_to_text(buf: &ftui::Buffer, pool: &ftui::GraphemePool) -> String {
15095 let mut out = String::with_capacity((buf.width() as usize + 1) * buf.height() as usize);
15096 for y in 0..buf.height() {
15097 if y > 0 {
15098 out.push('\n');
15099 }
15100 let mut row = String::with_capacity(buf.width() as usize);
15101 for x in 0..buf.width() {
15102 if let Some(cell) = buf.get(x, y) {
15103 if cell.is_continuation() {
15104 continue;
15105 }
15106 if cell.is_empty() {
15107 row.push(' ');
15108 } else if let Some(c) = cell.content.as_char() {
15109 row.push(c);
15110 } else if let Some(gid) = cell.content.grapheme_id() {
15111 if let Some(text) = pool.get(gid) {
15112 row.push_str(text);
15113 } else {
15114 row.push('?');
15115 }
15116 } else {
15117 row.push(' ');
15118 }
15119 } else {
15120 row.push(' ');
15121 }
15122 }
15123 let trimmed = row.trim_end();
15124 out.push_str(trimmed);
15125 }
15126 out
15127}
15128
15129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15130enum DebugRenderKind {
15131 View,
15132 Layout,
15133 HitTest,
15134 Capture,
15135}
15136
15137fn parse_debug_render_target(view_name: &str) -> Result<(ViewMode, DebugRenderKind)> {
15138 let (base_name, kind) = if let Some(base) = view_name.strip_suffix("-layout") {
15139 (base, DebugRenderKind::Layout)
15140 } else if let Some(base) = view_name.strip_suffix("-hittest") {
15141 (base, DebugRenderKind::HitTest)
15142 } else if let Some(base) = view_name.strip_suffix("-capture") {
15143 (base, DebugRenderKind::Capture)
15144 } else {
15145 (view_name, DebugRenderKind::View)
15146 };
15147
15148 let mode = match base_name {
15149 "insights" => ViewMode::Insights,
15150 "board" => ViewMode::Board,
15151 "history" => ViewMode::History,
15152 "main" => ViewMode::Main,
15153 "graph" => ViewMode::Graph,
15154 other => {
15155 return Err(BvrError::InvalidArgument(format!(
15156 "Unknown debug-render view '{other}'. Supported: insights, board, history, main, graph"
15157 )));
15158 }
15159 };
15160
15161 Ok((mode, kind))
15162}
15163
15164fn rect_debug_line(label: &str, area: Rect) -> String {
15165 format!(
15166 "{label:<14} x={} y={} w={} h={}",
15167 area.x, area.y, area.width, area.height
15168 )
15169}
15170
15171fn debug_layout_rects(app: &BvrApp, width: u16, height: u16) -> Vec<(&'static str, Rect)> {
15172 let full = Rect::from_size(width, height);
15173 let rows = Flex::vertical()
15174 .constraints([
15175 Constraint::Fixed(1),
15176 Constraint::Min(3),
15177 Constraint::Fixed(1),
15178 ])
15179 .split(full);
15180 let body = rows[1];
15181 let bp = Breakpoint::from_width(width);
15182 let split_state = pane_split_state();
15183 let graph_single_pane = matches!(app.mode, ViewMode::Graph) && matches!(bp, Breakpoint::Narrow);
15184 let history_layout = if matches!(app.mode, ViewMode::History) {
15185 HistoryLayout::from_width(body.width)
15186 } else {
15187 HistoryLayout::Narrow
15188 };
15189 let history_multi_pane =
15190 matches!(app.mode, ViewMode::History) && history_layout.has_middle_pane();
15191 let mut rects = vec![("header", rows[0]), ("body", body), ("footer", rows[2])];
15192
15193 if graph_single_pane {
15194 rects.push(("detail", body));
15195 return rects;
15196 }
15197
15198 if history_multi_pane {
15199 if matches!(history_layout, HistoryLayout::Wide)
15200 && matches!(app.history_view_mode, HistoryViewMode::Bead)
15201 {
15202 let PaneSplitPreset::Four(pcts) =
15203 split_state.history_pcts(history_layout, app.history_view_mode)
15204 else {
15205 unreachable!("wide bead history should use four-pane split");
15206 };
15207 let panes = Flex::horizontal()
15208 .constraints([
15209 Constraint::Percentage(pcts[0]),
15210 Constraint::Percentage(pcts[1]),
15211 Constraint::Percentage(pcts[2]),
15212 Constraint::Percentage(pcts[3]),
15213 ])
15214 .split(body);
15215 rects.push(("list", panes[0]));
15216 rects.push(("timeline", panes[1]));
15217 rects.push(("middle", panes[2]));
15218 rects.push(("detail", panes[3]));
15219 } else {
15220 let PaneSplitPreset::Three(pane_widths) =
15221 split_state.history_pcts(history_layout, app.history_view_mode)
15222 else {
15223 unreachable!("multi-pane history should use three-pane split");
15224 };
15225 let panes = Flex::horizontal()
15226 .constraints([
15227 Constraint::Percentage(pane_widths[0]),
15228 Constraint::Percentage(pane_widths[1]),
15229 Constraint::Percentage(pane_widths[2]),
15230 ])
15231 .split(body);
15232 rects.push(("list", panes[0]));
15233 rects.push(("middle", panes[1]));
15234 rects.push(("detail", panes[2]));
15235 }
15236 return rects;
15237 }
15238
15239 let panes = Flex::horizontal()
15240 .constraints([
15241 Constraint::Percentage(split_state.two_pane_list_pct(bp)),
15242 Constraint::Percentage(split_state.two_pane_detail_pct(bp)),
15243 ])
15244 .split(body);
15245 rects.push(("list", panes[0]));
15246 rects.push(("detail", panes[1]));
15247 rects
15248}
15249
15250fn render_layout_debug_report(app: &BvrApp, width: u16, height: u16) -> String {
15251 let rects = debug_layout_rects(app, width, height);
15252 let mut lines = vec![
15253 format!(
15254 "Layout Debug | view={} | focus={}",
15255 app.mode.label(),
15256 app.focus.label()
15257 ),
15258 format!(
15259 "viewport w={} h={} breakpoint={:?}",
15260 width,
15261 height,
15262 Breakpoint::from_width(width)
15263 ),
15264 ];
15265 lines.extend(
15266 rects
15267 .into_iter()
15268 .map(|(label, area)| rect_debug_line(label, area)),
15269 );
15270
15271 let detail_area = cached_detail_content_area();
15272 if detail_area.width > 0 && detail_area.height > 0 {
15273 lines.push(rect_debug_line("detail-content", detail_area));
15274 }
15275
15276 lines.join("\n")
15277}
15278
15279fn render_hittest_debug_report(app: &BvrApp, width: u16, height: u16) -> String {
15280 let mut lines = vec![
15281 format!(
15282 "HitTest Debug | view={} | focus={}",
15283 app.mode.label(),
15284 app.focus.label()
15285 ),
15286 format!("viewport w={} h={}", width, height),
15287 ];
15288
15289 let detail_area = cached_detail_content_area();
15290 lines.push(rect_debug_line("detail-content", detail_area));
15291
15292 for tab in header_mode_tabs(app, width) {
15293 lines.push(rect_debug_line(
15294 &format!("tab-{}", tab.mode.label().to_ascii_lowercase()),
15295 tab.rect,
15296 ));
15297 }
15298
15299 if let Some(link_area) = app.current_detail_link_row_area() {
15300 lines.push(rect_debug_line("link-row", link_area));
15301 let center_x = link_area.x.saturating_add(link_area.width / 2);
15302 let center_y = link_area.y;
15303 lines.push(format!(
15304 "link-center x={} y={} inside={}",
15305 center_x,
15306 center_y,
15307 rect_contains(link_area, center_x, center_y)
15308 ));
15309 } else {
15310 lines.push("link-row none".to_string());
15311 }
15312
15313 for (index, hit_box) in splitter_hit_boxes(app, width, height)
15314 .into_iter()
15315 .enumerate()
15316 {
15317 lines.push(rect_debug_line(&format!("splitter-{index}"), hit_box.rect));
15318 }
15319
15320 lines.join("\n")
15321}
15322
15323fn render_capture_debug_report(app: &BvrApp, rendered: &str, width: u16, height: u16) -> String {
15324 format!(
15325 "Capture Debug | view={} | focus={} | selected={} | trace-len={}\nviewport w={} h={}\n\n--- render ---\n{}\n\n--- layout ---\n{}\n\n--- hittest ---\n{}",
15326 app.mode.label(),
15327 app.focus.label(),
15328 app.selected,
15329 debug_trace_len(app),
15330 width,
15331 height,
15332 rendered,
15333 render_layout_debug_report(app, width, height),
15334 render_hittest_debug_report(app, width, height),
15335 )
15336}
15337
15338#[cfg(test)]
15339fn debug_trace_len(app: &BvrApp) -> usize {
15340 app.key_trace.len()
15341}
15342
15343#[cfg(not(test))]
15344fn debug_trace_len(_app: &BvrApp) -> usize {
15345 0
15346}
15347
15348#[cfg(test)]
15349mod tests {
15350 use super::{
15351 BackgroundTickDecision, BoardGrouping, BvrApp, CommandHint, EmptyLaneVisibility, FocusPane,
15352 GitCommitRecord, HistoryBeadCompat, HistoryCommitCompat, HistoryGitCache, HistoryLayout,
15353 HistoryMilestonesCompat, HistorySearchMode, HistoryViewMode, InsightsPanel, ListFilter,
15354 ListSort, ModalOverlay, MouseButton, MouseEvent, MouseEventKind, Msg, ScanLineContext,
15355 SemanticTone, ViewMode, background_warning_message, blocker_indicator, buffer_to_text,
15356 build_header_text, cached_detail_content_area, center_display, command_hint_width,
15357 compact_history_duration_label, decide_background_tick, display_width, fit_display,
15358 history_legacy_lifecycle_lines, issue_scan_line, label_chips,
15359 legacy_history_author_initials, metric_strip, panel_header, priority_badge,
15360 record_view_size, render_debug_view, saturating_scroll_offset, section_separator,
15361 should_apply_background_reload, sprint_reference_now, status_chip,
15362 styled_detail_summary_line, truncate_display, type_badge, wrap_command_hints,
15363 };
15364 use crate::analysis::Analyzer;
15365 use crate::analysis::diff::FieldChange;
15366 use crate::analysis::git_history::{
15367 HistoryCycleCompat, HistoryEventCompat, HistoryFileChangeCompat,
15368 };
15369 use crate::analysis::label_intel::CrossLabelFlow;
15370 use crate::model::{Comment, Dependency, Issue, Sprint, ts};
15371 use chrono::Utc;
15372 use ftui::core::event::{KeyCode, Modifiers};
15373 use ftui::runtime::{Cmd, Model};
15374 use ftui::text::{Line as RichLine, Span as RichSpan};
15375 use std::cell::Cell;
15376 use std::collections::BTreeMap;
15377 use std::fmt::Write as _;
15378
15379 #[derive(Debug, Clone)]
15380 struct DebugReplayCapture {
15381 step: String,
15382 mode: ViewMode,
15383 focus: FocusPane,
15384 selected: usize,
15385 width: u16,
15386 height: u16,
15387 trace_len: usize,
15388 rendered: String,
15389 layout: String,
15390 hittest: String,
15391 }
15392
15393 fn sample_issues() -> Vec<Issue> {
15394 vec![
15395 Issue {
15396 id: "A".to_string(),
15397 title: "Root".to_string(),
15398 status: "open".to_string(),
15399 issue_type: "task".to_string(),
15400 description: "Top-level issue that unblocks downstream work once the shared baseline is solid."
15401 .to_string(),
15402 design: "Keep the main flow lean, surface the most important context first, and let supporting sections trail after the summary."
15403 .to_string(),
15404 acceptance_criteria:
15405 "- Detail summary shows triage and graph context.\n- Rich body sections stay readable across narrow and wide panes."
15406 .to_string(),
15407 notes: "Fixture used to exercise the richer main detail pane.".to_string(),
15408 assignee: "alice".to_string(),
15409 estimated_minutes: Some(90),
15410 created_at: ts("2026-01-01T00:00:00Z"),
15411 updated_at: ts("2026-01-02T00:00:00Z"),
15412 labels: vec!["core".to_string(), "parity".to_string()],
15413 comments: vec![
15414 Comment {
15415 id: 1,
15416 issue_id: "A".to_string(),
15417 author: "alice".to_string(),
15418 text: "Need this baseline before the dependent slice can land."
15419 .to_string(),
15420 created_at: ts("2026-01-01T08:00:00Z"),
15421 },
15422 Comment {
15423 id: 2,
15424 issue_id: "A".to_string(),
15425 author: "bob".to_string(),
15426 text: "Verify the detail pane still reads well at narrow widths."
15427 .to_string(),
15428 created_at: ts("2026-01-02T09:30:00Z"),
15429 },
15430 ],
15431 source_repo: "viewer".to_string(),
15432 ..Issue::default()
15433 },
15434 Issue {
15435 id: "B".to_string(),
15436 title: "Dependent".to_string(),
15437 status: "open".to_string(),
15438 issue_type: "task".to_string(),
15439 description: "Depends on A and should report blocked state clearly.".to_string(),
15440 created_at: ts("2026-01-03T00:00:00Z"),
15441 updated_at: ts("2026-01-04T00:00:00Z"),
15442 dependencies: vec![Dependency {
15443 issue_id: "B".to_string(),
15444 depends_on_id: "A".to_string(),
15445 dep_type: "blocks".to_string(),
15446 ..Dependency::default()
15447 }],
15448 ..Issue::default()
15449 },
15450 Issue {
15451 id: "C".to_string(),
15452 title: "Closed".to_string(),
15453 status: "closed".to_string(),
15454 issue_type: "task".to_string(),
15455 created_at: ts("2026-01-01T00:00:00Z"),
15456 updated_at: ts("2026-01-06T00:00:00Z"),
15457 closed_at: ts("2026-01-06T00:00:00Z"),
15458 ..Issue::default()
15459 },
15460 ]
15461 }
15462
15463 fn lane_issues() -> Vec<Issue> {
15464 vec![
15465 Issue {
15466 id: "OPEN-1".to_string(),
15467 title: "Open".to_string(),
15468 status: "open".to_string(),
15469 issue_type: "task".to_string(),
15470 priority: 0,
15471 ..Issue::default()
15472 },
15473 Issue {
15474 id: "IP-1".to_string(),
15475 title: "In Progress".to_string(),
15476 status: "in_progress".to_string(),
15477 issue_type: "feature".to_string(),
15478 priority: 1,
15479 ..Issue::default()
15480 },
15481 Issue {
15482 id: "BLK-1".to_string(),
15483 title: "Blocked".to_string(),
15484 status: "blocked".to_string(),
15485 issue_type: "bug".to_string(),
15486 priority: 2,
15487 ..Issue::default()
15488 },
15489 Issue {
15490 id: "CLS-1".to_string(),
15491 title: "Closed".to_string(),
15492 status: "closed".to_string(),
15493 issue_type: "docs".to_string(),
15494 priority: 3,
15495 ..Issue::default()
15496 },
15497 ]
15498 }
15499
15500 fn board_nav_issues() -> Vec<Issue> {
15501 vec![
15502 Issue {
15503 id: "OPEN-1".to_string(),
15504 title: "Open Start".to_string(),
15505 status: "open".to_string(),
15506 issue_type: "task".to_string(),
15507 priority: 0,
15508 ..Issue::default()
15509 },
15510 Issue {
15511 id: "OPEN-2".to_string(),
15512 title: "Open End".to_string(),
15513 status: "open".to_string(),
15514 issue_type: "task".to_string(),
15515 priority: 1,
15516 ..Issue::default()
15517 },
15518 Issue {
15519 id: "IP-1".to_string(),
15520 title: "In Progress".to_string(),
15521 status: "in_progress".to_string(),
15522 issue_type: "feature".to_string(),
15523 priority: 1,
15524 ..Issue::default()
15525 },
15526 Issue {
15527 id: "CLS-1".to_string(),
15528 title: "Closed".to_string(),
15529 status: "closed".to_string(),
15530 issue_type: "docs".to_string(),
15531 priority: 3,
15532 ..Issue::default()
15533 },
15534 ]
15535 }
15536
15537 fn board_with_unknown_status_issues() -> Vec<Issue> {
15538 vec![
15539 Issue {
15540 id: "OPEN-1".to_string(),
15541 title: "Open".to_string(),
15542 status: "open".to_string(),
15543 issue_type: "task".to_string(),
15544 priority: 0,
15545 ..Issue::default()
15546 },
15547 Issue {
15548 id: "QUE-1".to_string(),
15549 title: "Queued".to_string(),
15550 status: "queued".to_string(),
15551 issue_type: "task".to_string(),
15552 priority: 1,
15553 ..Issue::default()
15554 },
15555 ]
15556 }
15557
15558 fn sortable_issues() -> Vec<Issue> {
15559 vec![
15560 Issue {
15561 id: "Z".to_string(),
15562 title: "Oldest".to_string(),
15563 status: "open".to_string(),
15564 issue_type: "task".to_string(),
15565 priority: 3,
15566 created_at: ts("2026-01-01T00:00:00Z"),
15567 updated_at: ts("2026-01-06T00:00:00Z"),
15568 ..Issue::default()
15569 },
15570 Issue {
15571 id: "A".to_string(),
15572 title: "Middle".to_string(),
15573 status: "open".to_string(),
15574 issue_type: "task".to_string(),
15575 priority: 2,
15576 created_at: ts("2026-01-02T00:00:00Z"),
15577 updated_at: ts("2026-01-05T00:00:00Z"),
15578 ..Issue::default()
15579 },
15580 Issue {
15581 id: "M".to_string(),
15582 title: "Newest".to_string(),
15583 status: "open".to_string(),
15584 issue_type: "task".to_string(),
15585 priority: 1,
15586 created_at: ts("2026-01-03T00:00:00Z"),
15587 updated_at: ts("2026-01-04T00:00:00Z"),
15588 ..Issue::default()
15589 },
15590 ]
15591 }
15592
15593 fn graph_many_blocker_issues() -> Vec<Issue> {
15594 let mut issues = vec![Issue {
15595 id: "MAIN".to_string(),
15596 title: "Main Issue".to_string(),
15597 status: "open".to_string(),
15598 issue_type: "task".to_string(),
15599 dependencies: (0..10)
15600 .map(|idx| Dependency {
15601 issue_id: "MAIN".to_string(),
15602 depends_on_id: format!("BLK-{idx:02}"),
15603 dep_type: "blocks".to_string(),
15604 ..Dependency::default()
15605 })
15606 .collect(),
15607 ..Issue::default()
15608 }];
15609
15610 issues.extend((0..10).map(|idx| Issue {
15611 id: format!("BLK-{idx:02}"),
15612 title: format!("Blocker {idx:02}"),
15613 status: "open".to_string(),
15614 issue_type: "task".to_string(),
15615 ..Issue::default()
15616 }));
15617
15618 issues
15619 }
15620
15621 fn graph_many_dependent_issues() -> Vec<Issue> {
15622 let mut issues = vec![Issue {
15623 id: "ROOT".to_string(),
15624 title: "Root Issue".to_string(),
15625 status: "open".to_string(),
15626 issue_type: "task".to_string(),
15627 ..Issue::default()
15628 }];
15629
15630 issues.extend((0..10).map(|idx| Issue {
15631 id: format!("DEP-{idx:02}"),
15632 title: format!("Dependent {idx:02}"),
15633 status: "open".to_string(),
15634 issue_type: "task".to_string(),
15635 dependencies: vec![Dependency {
15636 issue_id: format!("DEP-{idx:02}"),
15637 depends_on_id: "ROOT".to_string(),
15638 dep_type: "blocks".to_string(),
15639 ..Dependency::default()
15640 }],
15641 ..Issue::default()
15642 }));
15643
15644 issues
15645 }
15646
15647 fn new_app(mode: ViewMode, selected: usize) -> BvrApp {
15648 let mut app = BvrApp {
15649 analyzer: Analyzer::new(sample_issues()),
15650 repo_root: None,
15651 selected,
15652 list_filter: ListFilter::All,
15653 list_sort: ListSort::Default,
15654 board_grouping: BoardGrouping::Status,
15655 board_empty_visibility: EmptyLaneVisibility::Auto,
15656 mode,
15657 mode_before_history: ViewMode::Main,
15658 mode_back_stack: Vec::new(),
15659 focus: FocusPane::List,
15660 focus_before_help: FocusPane::List,
15661 show_help: false,
15662 help_scroll_offset: 0,
15663 show_quit_confirm: false,
15664 modal_overlay: None,
15665 modal_confirm_result: None,
15666 history_confidence_index: 0,
15667 history_view_mode: HistoryViewMode::Bead,
15668 history_event_cursor: 0,
15669 history_related_bead_cursor: 0,
15670 history_bead_commit_cursor: 0,
15671 history_git_cache: None,
15672 history_search_active: false,
15673 history_search_query: String::new(),
15674 history_search_match_cursor: 0,
15675 history_search_mode: HistorySearchMode::All,
15676 history_show_file_tree: false,
15677 history_file_tree_cursor: 0,
15678 history_file_tree_filter: None,
15679 history_file_tree_focus: false,
15680 history_status_msg: String::new(),
15681 board_search_active: false,
15682 board_search_query: String::new(),
15683 board_search_match_cursor: 0,
15684 board_detail_scroll_offset: 0,
15685 detail_scroll_offset: 0,
15686 main_search_active: false,
15687 main_search_query: String::new(),
15688 main_search_match_cursor: 0,
15689 list_scroll_offset: Cell::new(0),
15690 list_viewport_height: Cell::new(0),
15691 graph_search_active: false,
15692 graph_search_query: String::new(),
15693 graph_search_match_cursor: 0,
15694 insights_search_active: false,
15695 insights_search_query: String::new(),
15696 insights_search_match_cursor: 0,
15697 insights_panel: InsightsPanel::Bottlenecks,
15698 insights_heatmap: None,
15699 insights_show_explanations: true,
15700 insights_show_calc_proof: false,
15701 detail_dep_cursor: 0,
15702 actionable_plan: None,
15703 actionable_track_cursor: 0,
15704 actionable_item_cursor: 0,
15705 attention_result: None,
15706 attention_cursor: 0,
15707 tree_flat_nodes: Vec::new(),
15708 tree_cursor: 0,
15709 tree_collapsed: std::collections::HashSet::new(),
15710 tree_search_active: false,
15711 tree_search_query: String::new(),
15712 tree_search_match_cursor: 0,
15713 pending_g: false,
15714 g_pre_toggle_mode: None,
15715 pending_z: false,
15716 label_dashboard: None,
15717 label_dashboard_cursor: 0,
15718 flow_matrix: None,
15719 flow_matrix_row_cursor: 0,
15720 flow_matrix_col_cursor: 0,
15721 time_travel_ref_input: String::new(),
15722 time_travel_input_active: false,
15723 time_travel_diff: None,
15724 time_travel_category_cursor: 0,
15725 time_travel_issue_cursor: 0,
15726 time_travel_last_ref: None,
15727 sprint_data: Vec::new(),
15728 sprint_cursor: 0,
15729 sprint_issue_cursor: 0,
15730 modal_label_filter: None,
15731 modal_repo_filter: None,
15732 priority_hints_visible: false,
15733 status_msg: String::new(),
15734 slow_metrics_pending: false,
15735 #[cfg(test)]
15736 key_trace: Vec::new(),
15737 };
15738 app.reset_pane_split_state();
15739 app
15740 }
15741
15742 fn new_app_with_issues(mode: ViewMode, selected: usize, issues: Vec<Issue>) -> BvrApp {
15743 let mut app = new_app(mode, selected);
15744 app.analyzer = Analyzer::new(issues);
15745 app.selected = selected;
15746 app
15747 }
15748
15749 fn history_file_change(path: &str) -> HistoryFileChangeCompat {
15750 HistoryFileChangeCompat {
15751 path: path.to_string(),
15752 action: "M".to_string(),
15753 insertions: 1,
15754 deletions: 0,
15755 }
15756 }
15757
15758 fn history_commit(
15759 sha: &str,
15760 message: &str,
15761 confidence: f64,
15762 paths: &[&str],
15763 ) -> HistoryCommitCompat {
15764 HistoryCommitCompat {
15765 sha: sha.to_string(),
15766 short_sha: sha[..7.min(sha.len())].to_string(),
15767 message: message.to_string(),
15768 author: "Test Author".to_string(),
15769 author_email: "test@example.com".to_string(),
15770 timestamp: "2026-01-10T00:00:00Z".to_string(),
15771 files: paths.iter().map(|path| history_file_change(path)).collect(),
15772 method: "co_committed".to_string(),
15773 confidence,
15774 reason: "fixture".to_string(),
15775 field_changes: vec![],
15776 bead_diff_lines: vec![],
15777 }
15778 }
15779
15780 fn git_commit_record(sha: &str, message: &str, paths: &[&str]) -> GitCommitRecord {
15781 GitCommitRecord {
15782 sha: sha.to_string(),
15783 short_sha: sha[..7.min(sha.len())].to_string(),
15784 timestamp: "2026-01-10T00:00:00Z".to_string(),
15785 author: "Test Author".to_string(),
15786 author_email: "test@example.com".to_string(),
15787 message: message.to_string(),
15788 files: paths.iter().map(|path| history_file_change(path)).collect(),
15789 changed_beads: true,
15790 changed_non_beads: true,
15791 }
15792 }
15793
15794 fn history_app_with_git_cache(view_mode: HistoryViewMode, selected: usize) -> BvrApp {
15795 let ui_commit = history_commit(
15796 "aaaa1111",
15797 "feat: ui wiring",
15798 0.95,
15799 &["src/ui/app.rs", "src/ui/detail.rs"],
15800 );
15801 let core_commit =
15802 history_commit("bbbb2222", "feat: graph core", 0.80, &["src/core/graph.rs"]);
15803 let docs_commit = history_commit("cccc3333", "docs: readme", 0.90, &["README.md"]);
15804 let build_commit = history_commit("dddd4444", "chore: cargo polish", 0.85, &["Cargo.toml"]);
15805
15806 let mut histories = BTreeMap::new();
15807 histories.insert(
15808 "A".to_string(),
15809 HistoryBeadCompat {
15810 bead_id: "A".to_string(),
15811 title: "Root".to_string(),
15812 status: "open".to_string(),
15813 events: Vec::new(),
15814 milestones: HistoryMilestonesCompat::default(),
15815 commits: Some(vec![ui_commit.clone(), core_commit.clone()]),
15816 cycle_time: None,
15817 last_author: "Alice".to_string(),
15818 },
15819 );
15820 histories.insert(
15821 "B".to_string(),
15822 HistoryBeadCompat {
15823 bead_id: "B".to_string(),
15824 title: "Dependent".to_string(),
15825 status: "open".to_string(),
15826 events: Vec::new(),
15827 milestones: HistoryMilestonesCompat::default(),
15828 commits: Some(vec![docs_commit.clone(), build_commit.clone()]),
15829 cycle_time: None,
15830 last_author: "Bob".to_string(),
15831 },
15832 );
15833
15834 let mut commit_bead_confidence = BTreeMap::new();
15835 commit_bead_confidence.insert("aaaa1111".to_string(), vec![("A".to_string(), 0.95)]);
15836 commit_bead_confidence.insert("bbbb2222".to_string(), vec![("A".to_string(), 0.80)]);
15837 commit_bead_confidence.insert("cccc3333".to_string(), vec![("B".to_string(), 0.90)]);
15838 commit_bead_confidence.insert("dddd4444".to_string(), vec![("B".to_string(), 0.85)]);
15839
15840 let mut app = new_app(ViewMode::History, selected);
15841 app.history_view_mode = view_mode;
15842 app.history_git_cache = Some(HistoryGitCache {
15843 commits: vec![
15844 git_commit_record(
15845 "aaaa1111",
15846 "feat: ui wiring",
15847 &["src/ui/app.rs", "src/ui/detail.rs"],
15848 ),
15849 git_commit_record("bbbb2222", "feat: graph core", &["src/core/graph.rs"]),
15850 git_commit_record("cccc3333", "docs: readme", &["README.md"]),
15851 git_commit_record("dddd4444", "chore: cargo polish", &["Cargo.toml"]),
15852 ],
15853 histories,
15854 commit_bead_confidence,
15855 });
15856 app
15857 }
15858
15859 fn inject_deterministic_git_cache(app: &mut BvrApp) {
15862 let ui_commit = history_commit(
15863 "aaaa1111",
15864 "feat: ui wiring",
15865 0.95,
15866 &["src/ui/app.rs", "src/ui/detail.rs"],
15867 );
15868 let core_commit =
15869 history_commit("bbbb2222", "feat: graph core", 0.80, &["src/core/graph.rs"]);
15870
15871 let mut histories = BTreeMap::new();
15872 for issue in &app.analyzer.issues {
15873 histories.insert(
15874 issue.id.clone(),
15875 HistoryBeadCompat {
15876 bead_id: issue.id.clone(),
15877 title: issue.title.clone(),
15878 status: issue.status.clone(),
15879 events: Vec::new(),
15880 milestones: HistoryMilestonesCompat::default(),
15881 commits: None,
15882 cycle_time: None,
15883 last_author: String::new(),
15884 },
15885 );
15886 }
15887
15888 if let Some(history) = histories.get_mut("B") {
15890 history.commits = Some(vec![ui_commit.clone(), core_commit.clone()]);
15891 history.last_author = "Test Author".to_string();
15892 }
15893
15894 let mut commit_bead_confidence = BTreeMap::new();
15895 commit_bead_confidence.insert("aaaa1111".to_string(), vec![("B".to_string(), 0.95)]);
15896 commit_bead_confidence.insert("bbbb2222".to_string(), vec![("B".to_string(), 0.80)]);
15897
15898 app.history_git_cache = Some(HistoryGitCache {
15899 commits: vec![
15900 git_commit_record(
15901 "aaaa1111",
15902 "feat: ui wiring",
15903 &["src/ui/app.rs", "src/ui/detail.rs"],
15904 ),
15905 git_commit_record("bbbb2222", "feat: graph core", &["src/core/graph.rs"]),
15906 ],
15907 histories,
15908 commit_bead_confidence,
15909 });
15910 }
15911
15912 fn key(code: KeyCode) -> Msg {
15913 Msg::KeyPress(code, Modifiers::empty())
15914 }
15915
15916 fn key_ctrl(code: KeyCode) -> Msg {
15917 Msg::KeyPress(code, Modifiers::CTRL)
15918 }
15919
15920 fn key_backtab() -> Msg {
15921 Msg::KeyPress(KeyCode::BackTab, Modifiers::SHIFT)
15922 }
15923
15924 fn mouse(kind: MouseEventKind, x: u16, y: u16) -> Msg {
15925 Msg::Mouse(MouseEvent::new(kind, x, y))
15926 }
15927
15928 fn selected_issue_id(app: &BvrApp) -> String {
15929 app.analyzer
15930 .issues
15931 .get(app.selected)
15932 .map(|issue| issue.id.clone())
15933 .unwrap_or_default()
15934 }
15935
15936 fn first_rendered_issue_id(app: &BvrApp) -> String {
15937 let indices = app.visible_issue_indices();
15938 indices
15939 .first()
15940 .and_then(|&idx| app.analyzer.issues.get(idx))
15941 .map(|issue| issue.id.clone())
15942 .unwrap_or_default()
15943 }
15944
15945 #[test]
15946 fn background_tick_decision_prioritizes_cancel_then_in_flight() {
15947 assert_eq!(
15948 decide_background_tick(true, false),
15949 BackgroundTickDecision::Stop
15950 );
15951 assert_eq!(
15952 decide_background_tick(true, true),
15953 BackgroundTickDecision::Stop
15954 );
15955 assert_eq!(
15956 decide_background_tick(false, true),
15957 BackgroundTickDecision::TickOnly
15958 );
15959 assert_eq!(
15960 decide_background_tick(false, false),
15961 BackgroundTickDecision::ReloadAndTick
15962 );
15963 }
15964
15965 #[test]
15966 fn background_reload_apply_requires_no_cancel_and_hash_change() {
15967 assert!(should_apply_background_reload(
15968 false, "new-hash", "old-hash"
15969 ));
15970 assert!(!should_apply_background_reload(
15971 false,
15972 "same-hash",
15973 "same-hash"
15974 ));
15975 assert!(!should_apply_background_reload(
15976 true, "new-hash", "old-hash"
15977 ));
15978 }
15979
15980 #[test]
15981 fn background_warning_message_suppresses_canceled_paths() {
15982 assert_eq!(background_warning_message(true, "boom"), None);
15983 assert_eq!(background_warning_message(false, "canceled"), None);
15984 assert_eq!(
15985 background_warning_message(false, "permission denied").as_deref(),
15986 Some("background reload warning: permission denied")
15987 );
15988 }
15989
15990 #[test]
15991 fn render_debug_view_supports_all_named_views() {
15992 for view in [
15993 "insights",
15994 "board",
15995 "history",
15996 "main",
15997 "graph",
15998 "main-layout",
15999 "history-layout",
16000 "graph-layout",
16001 "main-hittest",
16002 "graph-hittest",
16003 "main-capture",
16004 ] {
16005 let output =
16006 render_debug_view(sample_issues(), view, 80, 12).expect("debug render succeeds");
16007 if view.contains("-layout") || view.contains("-hittest") || view.contains("-capture") {
16008 assert!(
16009 !output.is_empty(),
16010 "diagnostic debug render should return content for view {view}"
16011 );
16012 } else {
16013 assert_eq!(
16014 output.lines().count(),
16015 12,
16016 "expected one line per requested row for view {view}"
16017 );
16018 }
16019 }
16020 }
16021
16022 #[test]
16023 fn render_debug_view_respects_dimensions() {
16024 let width = 32_u16;
16025 let height = 7_u16;
16026 let output = render_debug_view(sample_issues(), "main", width, height)
16027 .expect("main debug render succeeds");
16028 let lines: Vec<&str> = output.lines().collect();
16029
16030 assert_eq!(lines.len(), usize::from(height));
16031 assert!(
16032 lines
16033 .iter()
16034 .all(|line| display_width(line) <= usize::from(width)),
16035 "every rendered line should fit within the requested width"
16036 );
16037 }
16038
16039 #[test]
16040 fn render_debug_view_rejects_unknown_view() {
16041 let error = render_debug_view(sample_issues(), "bogus", 80, 10)
16042 .expect_err("unknown view should fail");
16043 let message = error.to_string();
16044 assert!(message.contains("Unknown debug-render view 'bogus'"));
16045 assert!(message.contains("insights, board, history, main, graph"));
16046 }
16047
16048 #[test]
16049 fn render_debug_view_layout_reports_pane_rects() {
16050 let output =
16051 render_debug_view(sample_issues(), "main-layout", 100, 20).expect("layout debug");
16052 assert!(output.contains("Layout Debug | view=Main"));
16053 assert!(output.contains("header"));
16054 assert!(output.contains("list"));
16055 assert!(output.contains("detail"));
16056 assert!(output.contains("detail-content"));
16057 }
16058
16059 #[test]
16060 fn render_debug_view_hittest_reports_link_row() {
16061 let mut issues = sample_issues();
16062 issues[0].external_ref = Some("https://github.com/org/repo/issues/42".into());
16063 let output = render_debug_view(issues, "main-hittest", 100, 20).expect("hittest debug");
16064 assert!(output.contains("HitTest Debug | view=Main"));
16065 assert!(output.contains("detail-content"));
16066 assert!(output.contains("tab-main"));
16067 assert!(output.contains("tab-board"));
16068 assert!(output.contains("link-row"));
16069 assert!(output.contains("link-center"));
16070 assert!(output.contains("splitter-0"));
16071 }
16072
16073 #[test]
16074 fn render_debug_view_history_layout_reports_timeline_rects() {
16075 let output =
16076 render_debug_view(sample_issues(), "history-layout", 160, 24).expect("layout debug");
16077 assert!(output.contains("Layout Debug | view=History"));
16078 assert!(output.contains("timeline"));
16079 assert!(output.contains("middle"));
16080 assert!(output.contains("detail"));
16081 }
16082
16083 #[test]
16084 fn render_debug_view_graph_hittest_reports_link_row() {
16085 let mut issues = sample_issues();
16086 issues[0].external_ref = Some("https://github.com/org/repo/issues/42".into());
16087 let output =
16088 render_debug_view(issues, "graph-hittest", 100, 20).expect("graph hittest debug");
16089 assert!(output.contains("HitTest Debug | view=Graph"));
16090 assert!(output.contains("link-row"));
16091 assert!(output.contains("link-center"));
16092 }
16093
16094 #[test]
16095 fn render_debug_view_capture_includes_render_layout_and_hittest_sections() {
16096 let mut issues = sample_issues();
16097 issues[0].external_ref = Some("https://github.com/org/repo/issues/42".into());
16098 let output = render_debug_view(issues, "main-capture", 100, 20).expect("capture debug");
16099 assert!(output.contains("Capture Debug | view=Main"));
16100 assert!(output.contains("--- render ---"));
16101 assert!(output.contains("--- layout ---"));
16102 assert!(output.contains("--- hittest ---"));
16103 assert!(output.contains("Layout Debug | view=Main"));
16104 assert!(output.contains("HitTest Debug | view=Main"));
16105 }
16106
16107 #[test]
16108 fn debug_replay_artifact_tracks_responsive_layout_and_trace_growth() {
16109 let mut app = history_app_with_git_cache(HistoryViewMode::Bead, 0);
16110 let mut captures = Vec::new();
16111
16112 capture_debug_replay(&app, 100, 24, "history_standard", &mut captures);
16113 app.update(key(KeyCode::Char('j')));
16114 app.update(key(KeyCode::Tab));
16115 capture_debug_replay(&app, 160, 24, "history_wide_detail", &mut captures);
16116
16117 let artifact = debug_replay_artifact("history responsive replay", &captures);
16118 assert!(artifact.contains("=== Debug Replay: history responsive replay ==="));
16119 assert!(artifact.contains("trace-len=0"));
16120 assert!(artifact.contains("trace-len=2"));
16121 assert!(artifact.contains("size=100x24"));
16122 assert!(artifact.contains("size=160x24"));
16123 assert!(artifact.contains("breakpoint=Medium"));
16124 assert!(artifact.contains("breakpoint=Wide"));
16125 assert!(artifact.contains("timeline"));
16126 assert!(artifact.contains("HitTest Debug | view=History"));
16127 }
16128
16129 #[test]
16130 fn graph_mode_renders_metric_sections() {
16131 let mut app = new_app(ViewMode::Graph, 0);
16132 app.mode = ViewMode::Graph;
16133 let text = app.detail_panel_text();
16134 assert!(text.contains("Graph:"));
16135 assert!(text.contains("PageRank"));
16136 assert!(text.contains("GRAPH METRICS"));
16137 assert!(text.contains("Importance:"));
16138 assert!(text.contains("Betweenness"));
16139 assert!(text.contains("Top PageRank"));
16140 }
16141
16142 #[test]
16143 fn graph_mode_narrow_uses_single_detail_pane_layout() {
16144 let text = render_frame(ViewMode::Graph, 60, 30);
16145 assert!(text.contains("Graph View [focus]"));
16146 assert!(!text.contains("Graph Nodes"));
16147 assert!(text.contains("Focus: node 1/3 -> A (Root)"));
16148 assert!(text.contains("Focused edge: list focus"));
16149 }
16150
16151 #[test]
16152 fn board_mode_renders_lane_summary() {
16153 let mut app = new_app(ViewMode::Board, 1);
16154 app.mode = ViewMode::Board;
16155 let list = app.list_panel_text();
16156 let detail = app.detail_panel_text();
16157 assert!(list.contains("Lane"));
16158 assert!(detail.contains("Lane:"));
16159 assert!(detail.contains('B'));
16160 }
16161
16162 #[test]
16163 fn insights_mode_renders_rank_sections() {
16164 let mut app = new_app(ViewMode::Insights, 0);
16165 app.mode = ViewMode::Insights;
16166 let list = app.list_panel_text();
16167 let detail = app.detail_panel_text();
16168 assert!(list.contains("[Bottlenecks]"));
16169 assert!(list.contains("Signal Tiles"));
16170 assert!(list.contains("Outlier Radar"));
16171 assert!(detail.contains("Analytics Cockpit"));
16172 assert!(detail.contains("Critical Path Head"));
16173 }
16174
16175 #[test]
16176 fn insights_panel_s_cycles_through_all_panels() {
16177 let mut app = new_app(ViewMode::Insights, 0);
16178 app.mode = ViewMode::Insights;
16179 assert!(matches!(app.insights_panel, InsightsPanel::Bottlenecks));
16180
16181 app.update(key(KeyCode::Char('s')));
16182 assert!(matches!(app.insights_panel, InsightsPanel::Keystones));
16183 let list = app.list_panel_text();
16184 assert!(list.contains("[Keystones]"));
16185
16186 app.update(key(KeyCode::Char('s')));
16187 assert!(matches!(app.insights_panel, InsightsPanel::CriticalPath));
16188 let list = app.list_panel_text();
16189 assert!(list.contains("[Critical Path]"));
16190
16191 app.update(key(KeyCode::Char('s')));
16192 assert!(matches!(app.insights_panel, InsightsPanel::Influencers));
16193
16194 app.update(key(KeyCode::Char('s')));
16195 assert!(matches!(app.insights_panel, InsightsPanel::Betweenness));
16196
16197 app.update(key(KeyCode::Char('s')));
16198 assert!(matches!(app.insights_panel, InsightsPanel::Hubs));
16199
16200 app.update(key(KeyCode::Char('s')));
16201 assert!(matches!(app.insights_panel, InsightsPanel::Authorities));
16202
16203 app.update(key(KeyCode::Char('s')));
16204 assert!(matches!(app.insights_panel, InsightsPanel::Cores));
16205
16206 app.update(key(KeyCode::Char('s')));
16207 assert!(matches!(app.insights_panel, InsightsPanel::CutPoints));
16208
16209 app.update(key(KeyCode::Char('s')));
16210 assert!(matches!(app.insights_panel, InsightsPanel::Slack));
16211
16212 app.update(key(KeyCode::Char('s')));
16213 assert!(matches!(app.insights_panel, InsightsPanel::Cycles));
16214
16215 app.update(key(KeyCode::Char('s')));
16216 assert!(matches!(app.insights_panel, InsightsPanel::Priority));
16217 let list = app.list_panel_text();
16218 assert!(list.contains("[Priority]"));
16219
16220 app.update(key(KeyCode::Char('s')));
16222 assert!(matches!(app.insights_panel, InsightsPanel::Bottlenecks));
16223
16224 app.update(key(KeyCode::Char('S')));
16226 assert!(matches!(app.insights_panel, InsightsPanel::Priority));
16227 }
16228
16229 #[test]
16230 fn insights_keystones_and_priority_panels_render_legacy_style_rows() {
16231 let mut app = new_app(ViewMode::Insights, 0);
16232 app.mode = ViewMode::Insights;
16233
16234 app.update(key(KeyCode::Char('s')));
16235 let keystones = app.list_panel_text();
16236 assert!(keystones.contains("[Keystones]"));
16237 assert!(keystones.contains("depth="));
16238 assert!(keystones.contains("unblocks="));
16239 assert!(keystones.contains("A"));
16240
16241 for _ in 0..10 {
16242 app.update(key(KeyCode::Char('s')));
16243 }
16244 let priority = app.list_panel_text();
16245 assert!(priority.contains("[Priority]"));
16246 assert!(priority.contains("score="));
16247 assert!(priority.contains("unblocks="));
16248 assert!(priority.contains("p"));
16249 }
16250
16251 #[test]
16252 fn insights_detail_shows_all_metrics_for_focused_issue() {
16253 let mut app = new_app(ViewMode::Insights, 0);
16254 app.mode = ViewMode::Insights;
16255 let detail = app.detail_panel_text();
16256 assert!(detail.contains("Metric Strip"));
16257 assert!(detail.contains("[Rank ]"));
16258 assert!(detail.contains("[Flow ]"));
16259 assert!(detail.contains("PageRank:"));
16260 assert!(detail.contains("Betweenness:"));
16261 assert!(detail.contains("Eigenvector:"));
16262 assert!(detail.contains("Hub (HITS):"));
16263 assert!(detail.contains("Auth (HITS):"));
16264 assert!(detail.contains("K-core:"));
16265 assert!(detail.contains("Crit depth:"));
16266 assert!(detail.contains("Slack:"));
16267 assert!(detail.contains("Cut point:"));
16268 }
16269
16270 #[test]
16271 fn insights_mode_e_and_x_toggle_explanations_and_calc_proof() {
16272 let mut app = new_app(ViewMode::Insights, 0);
16273 app.mode = ViewMode::Insights;
16274
16275 let initial = app.detail_panel_text();
16276 assert!(initial.contains("Critical Path Head"));
16277 assert!(!initial.contains("Calculation Proof:"));
16278
16279 app.update(key(KeyCode::Char('e')));
16280 assert!(!app.insights_show_explanations);
16281 let without_explanations = app.detail_panel_text();
16282 assert!(without_explanations.contains("Explanations hidden"));
16283 assert!(!without_explanations.contains("Critical Path Head"));
16284
16285 app.update(key(KeyCode::Char('x')));
16286 assert!(app.insights_show_calc_proof);
16287 let with_proof = app.detail_panel_text();
16288 assert!(with_proof.contains("Calculation Proof:"));
16289
16290 app.update(key(KeyCode::Char('e')));
16291 assert!(app.insights_show_explanations);
16292 let restored = app.detail_panel_text();
16293 assert!(restored.contains("Critical Path Head"));
16294 }
16295
16296 #[test]
16297 fn history_mode_renders_timeline_sections() {
16298 let mut app = new_app(ViewMode::History, 2);
16299 app.mode = ViewMode::History;
16300 let text = app.detail_panel_text();
16301 assert!(text.contains("History Summary"));
16302 assert!(text.contains("LIFECYCLE:"));
16303 assert!(text.contains("switch to git timeline"));
16304 assert!(text.contains("Min confidence filter"));
16305 }
16306
16307 #[test]
16308 fn graph_mode_snapshot_like_output_is_stable() {
16309 let app = new_app(ViewMode::Graph, 0);
16310 let text = app.detail_panel_text();
16311 let lines = text.lines().collect::<Vec<_>>();
16312 assert!(lines.first().is_some_and(|line| line.starts_with("Graph:")));
16313 assert!(lines.iter().any(|line| line.contains('A')));
16314 assert!(lines.iter().any(|line| line.contains("Top PageRank:")));
16315 }
16316
16317 #[test]
16318 fn graph_detail_text_uses_legacy_relationship_headers_and_legend() {
16319 let mut blocker_view = new_app(ViewMode::Graph, 1);
16320 blocker_view.mode = ViewMode::Graph;
16321 let blocker_text = blocker_view.detail_panel_text();
16322 assert!(blocker_text.contains("▲ BLOCKED BY (must complete first) ▲"));
16323 assert!(blocker_text.contains("┌"));
16324 assert!(blocker_text.contains("[o] A"));
16325 assert!(blocker_text.contains("Root"));
16326
16327 let mut dependent_view = new_app(ViewMode::Graph, 0);
16328 dependent_view.mode = ViewMode::Graph;
16329 let dependent_text = dependent_view.detail_panel_text();
16330 assert!(dependent_text.contains("▼ BLOCKS (waiting on this) ▼"));
16331 assert!(dependent_text.contains("┌"));
16332 assert!(dependent_text.contains("[o] B"));
16333 assert!(dependent_text.contains("Dependent"));
16334 assert!(dependent_text.contains("Legend: █ relative score | #N rank of 3 issues"));
16335 assert!(dependent_text.contains("Nav: h/l nodes | j/k nodes or focused edges"));
16336 }
16337
16338 #[test]
16339 fn graph_detail_text_renders_visual_graph_content() {
16340 let mut app = new_app(ViewMode::Graph, 0);
16341 app.mode = ViewMode::Graph;
16342
16343 let text = app.graph_detail_text_for_width(58);
16344 assert!(text.contains("╔"));
16345 assert!(text.contains("▼ BLOCKS (waiting on this) ▼"));
16346 assert!(text.contains("[o] B"));
16347 assert!(text.contains("Dependent"));
16348 assert!(text.lines().all(|line| display_width(line) <= 58));
16349 }
16350
16351 #[test]
16352 fn graph_detail_text_many_blockers_shows_overflow_summary() {
16353 let mut app = new_app_with_issues(ViewMode::Graph, 0, graph_many_blocker_issues());
16354 app.mode = ViewMode::Graph;
16355 app.select_issue_by_id("MAIN");
16356
16357 let text = app.graph_detail_text_for_width(120);
16358 assert!(text.contains("▲ BLOCKED BY (must complete first) ▲"));
16359 assert!(text.contains("[o] BLK-00"));
16360 assert!(text.contains("[o] BLK-04"));
16361 assert!(text.contains("Blocker 00"));
16362 assert!(text.contains("Blocker 04"));
16363 assert!(text.contains("+5 more"));
16364 assert!(!text.contains("BLK-05"));
16365 assert!(!text.contains("Blocker 05"));
16366 assert!(text.lines().all(|line| display_width(line) <= 120));
16367 }
16368
16369 #[test]
16370 fn graph_detail_text_many_dependents_shows_overflow_summary() {
16371 let mut app = new_app_with_issues(ViewMode::Graph, 0, graph_many_dependent_issues());
16372 app.mode = ViewMode::Graph;
16373 app.select_issue_by_id("ROOT");
16374
16375 let text = app.graph_detail_text_for_width(120);
16376 assert!(text.contains("▼ BLOCKS (waiting on this) ▼"));
16377 assert!(text.contains("[o] DEP-00"));
16378 assert!(text.contains("[o] DEP-04"));
16379 assert!(text.contains("Dependent 00"));
16380 assert!(text.contains("Dependent 04"));
16381 assert!(text.contains("+5 more"));
16382 assert!(!text.contains("DEP-05"));
16383 assert!(!text.contains("Dependent 05"));
16384 assert!(text.lines().all(|line| display_width(line) <= 120));
16385 }
16386
16387 #[test]
16388 fn history_mode_snapshot_like_output_is_stable() {
16389 let app = new_app(ViewMode::History, 2);
16390 let text = app.detail_panel_text();
16391 let lines = text.lines().collect::<Vec<_>>();
16392 assert!(
16393 lines
16394 .first()
16395 .is_some_and(|line| line.starts_with("History Summary:"))
16396 );
16397 assert!(lines.iter().any(|line| line.contains("Issue: C (Closed)")));
16398 assert!(
16399 lines
16400 .iter()
16401 .any(|line| line.contains("Create->Close cycle time:"))
16402 );
16403 assert!(lines.iter().any(|line| line.contains("LIFECYCLE:")));
16404 }
16405
16406 #[test]
16407 fn help_tab_focus_and_quit_confirm_match_legacy_behavior() {
16408 let mut app = new_app(ViewMode::Main, 0);
16409
16410 let cmd = app.update(key(KeyCode::Char('?')));
16411 assert!(matches!(cmd, Cmd::None));
16412 assert!(app.show_help);
16413 assert_eq!(app.focus, FocusPane::List);
16414
16415 let cmd = app.update(key(KeyCode::Char('x')));
16417 assert!(matches!(cmd, Cmd::None));
16418 assert!(app.show_help);
16419
16420 let cmd = app.update(key(KeyCode::Escape));
16422 assert!(matches!(cmd, Cmd::None));
16423 assert!(!app.show_help);
16424 assert_eq!(app.focus, FocusPane::List);
16425
16426 app.update(key(KeyCode::Tab));
16427 assert_eq!(app.focus, FocusPane::Detail);
16428 app.update(key(KeyCode::Tab));
16429 assert_eq!(app.focus, FocusPane::List);
16430
16431 let cmd = app.update(key(KeyCode::Escape));
16432 assert!(matches!(cmd, Cmd::None));
16433 assert!(app.show_quit_confirm);
16434
16435 let quit_cmd = app.update(key(KeyCode::Char('y')));
16436 assert!(matches!(quit_cmd, Cmd::Quit));
16437 }
16438
16439 #[test]
16440 fn escape_from_non_main_modes_returns_to_main() {
16441 for mode in [ViewMode::Board, ViewMode::Insights, ViewMode::Graph] {
16442 let mut app = new_app(mode, 0);
16443 let cmd = app.update(key(KeyCode::Escape));
16444 assert!(matches!(cmd, Cmd::None));
16445 assert!(matches!(app.mode, ViewMode::Main));
16446 assert!(!app.show_quit_confirm);
16447 }
16448 }
16449
16450 #[test]
16451 fn q_from_non_main_modes_returns_to_main_instead_of_quit() {
16452 for mode in [ViewMode::Board, ViewMode::Insights, ViewMode::Graph] {
16453 let mut app = new_app(mode, 0);
16454 let cmd = app.update(key(KeyCode::Char('q')));
16455 assert!(matches!(cmd, Cmd::None));
16456 assert!(matches!(app.mode, ViewMode::Main));
16457 }
16458 }
16459
16460 #[test]
16461 fn view_hotkeys_toggle_modes_back_to_main() {
16462 let mut app = new_app(ViewMode::Main, 0);
16463
16464 app.update(key(KeyCode::Char('b')));
16465 assert!(matches!(app.mode, ViewMode::Board));
16466 app.update(key(KeyCode::Char('b')));
16467 assert!(matches!(app.mode, ViewMode::Main));
16468
16469 app.update(key(KeyCode::Char('i')));
16470 assert!(matches!(app.mode, ViewMode::Insights));
16471 app.update(key(KeyCode::Char('i')));
16472 assert!(matches!(app.mode, ViewMode::Main));
16473
16474 app.update(key(KeyCode::Char('g')));
16475 assert!(matches!(app.mode, ViewMode::Graph));
16476 app.update(key(KeyCode::Char('g')));
16477 assert!(matches!(app.mode, ViewMode::Main));
16478 }
16479
16480 #[test]
16481 fn main_hotkey_from_graph_or_insights_resets_focus_to_list() {
16482 for mode in [ViewMode::Graph, ViewMode::Insights] {
16483 let mut app = new_app(mode, 1);
16484 app.focus = FocusPane::Detail;
16485
16486 app.update(key(KeyCode::Char('1')));
16487 assert!(matches!(app.mode, ViewMode::Main));
16488 assert_eq!(app.focus, FocusPane::List);
16489 assert_eq!(selected_issue_id(&app), "B");
16490 }
16491 }
16492
16493 #[test]
16494 fn history_toggle_and_escape_match_legacy_behavior() {
16495 let mut app = new_app(ViewMode::Main, 0);
16496
16497 app.update(key(KeyCode::Char('h')));
16498 assert!(matches!(app.mode, ViewMode::History));
16499 assert_eq!(app.focus, FocusPane::List);
16500
16501 app.update(key(KeyCode::Char('h')));
16502 assert!(matches!(app.mode, ViewMode::Main));
16503 assert_eq!(app.focus, FocusPane::List);
16504
16505 app.update(key(KeyCode::Char('h')));
16506 assert!(matches!(app.mode, ViewMode::History));
16507 app.update(key(KeyCode::Escape));
16508 assert!(matches!(app.mode, ViewMode::Main));
16509 assert!(!app.show_quit_confirm);
16510 }
16511
16512 #[test]
16513 fn insights_mode_h_l_switch_focus_panes() {
16514 let mut app = new_app(ViewMode::Insights, 0);
16515
16516 assert_eq!(app.focus, FocusPane::List);
16517 app.update(key(KeyCode::Char('l')));
16518 assert_eq!(app.focus, FocusPane::Detail);
16519 assert!(matches!(app.mode, ViewMode::Insights));
16520
16521 app.update(key(KeyCode::Char('h')));
16522 assert_eq!(app.focus, FocusPane::List);
16523 assert!(matches!(app.mode, ViewMode::Insights));
16524 }
16525
16526 #[test]
16527 fn insights_heatmap_toggle_and_escape_drill_preserve_mode() {
16528 let mut app = new_app(ViewMode::Insights, 0);
16529
16530 assert!(app.insights_heatmap.is_none());
16531 app.update(key(KeyCode::Char('m')));
16532 assert!(app.insights_heatmap.is_some());
16533 assert!(app.list_panel_text().contains("Priority heatmap |"));
16534
16535 app.update(key(KeyCode::Enter));
16536 assert!(
16537 app.insights_heatmap
16538 .as_ref()
16539 .is_some_and(|state| state.drill_active)
16540 );
16541 assert!(app.list_panel_text().contains("Priority heatmap drill"));
16542
16543 app.update(key(KeyCode::Escape));
16544 assert!(
16545 app.insights_heatmap
16546 .as_ref()
16547 .is_some_and(|state| !state.drill_active)
16548 );
16549 assert!(matches!(app.mode, ViewMode::Insights));
16550 }
16551
16552 #[test]
16553 fn insights_heatmap_empty_grid_renders_without_panic() {
16554 let issues = vec![Issue {
16555 id: "CLS-1".to_string(),
16556 title: "Closed".to_string(),
16557 status: "closed".to_string(),
16558 issue_type: "task".to_string(),
16559 ..Issue::default()
16560 }];
16561 let mut app = new_app_with_issues(ViewMode::Insights, 0, issues);
16562
16563 app.update(key(KeyCode::Char('m')));
16564 assert!(
16565 app.list_panel_text()
16566 .contains("no open, filter-matching issues")
16567 );
16568 assert!(
16569 app.detail_panel_text()
16570 .contains("No issues in the selected heatmap cell.")
16571 );
16572 }
16573
16574 #[test]
16575 fn insights_heatmap_drill_selection_syncs_detail_context() {
16576 let mut app = new_app(ViewMode::Insights, 0);
16577
16578 app.update(key(KeyCode::Char('m')));
16579 let selected = selected_issue_id(&app);
16580 let detail = app.detail_panel_text();
16581 assert!(detail.contains("Heatmap:"));
16582 assert!(detail.contains(&format!("Focus: {selected}")));
16583
16584 app.update(key(KeyCode::Enter));
16585 let drilled = app.detail_panel_text();
16586 assert!(drilled.contains("Drill selection:"));
16587 assert!(drilled.contains(&format!("Focus: {selected}")));
16588 }
16589
16590 #[test]
16591 fn insights_heatmap_list_keys_stay_in_list_focus() {
16592 let mut app = new_app(ViewMode::Insights, 0);
16593
16594 app.update(key(KeyCode::Char('m')));
16595 assert_eq!(app.focus, FocusPane::List);
16596
16597 app.update(key(KeyCode::Char('l')));
16598 assert_eq!(app.focus, FocusPane::List);
16599
16600 app.update(key(KeyCode::Char('j')));
16601 assert_eq!(app.focus, FocusPane::List);
16602 assert!(app.list_panel_text().contains("Priority heatmap"));
16603 }
16604
16605 #[test]
16606 fn graph_mode_h_l_and_ctrl_paging_move_selection() {
16607 let mut app = new_app(ViewMode::Graph, 0);
16608
16609 assert_eq!(selected_issue_id(&app), "A");
16610 app.update(key(KeyCode::Char('l')));
16611 assert_eq!(selected_issue_id(&app), "B");
16612 assert!(matches!(app.mode, ViewMode::Graph));
16613
16614 app.update(key(KeyCode::Char('h')));
16615 assert_eq!(selected_issue_id(&app), "A");
16616
16617 app.update(key_ctrl(KeyCode::Char('d')));
16618 assert_eq!(selected_issue_id(&app), "C");
16619
16620 app.update(key_ctrl(KeyCode::Char('u')));
16621 assert_eq!(selected_issue_id(&app), "A");
16622 }
16623
16624 #[test]
16625 fn graph_mode_shift_h_l_jump_by_page_window() {
16626 let mut app = new_app(ViewMode::Graph, 0);
16627
16628 assert_eq!(selected_issue_id(&app), "A");
16629 app.update(key(KeyCode::Char('L')));
16630 assert_eq!(selected_issue_id(&app), "C");
16631
16632 app.update(key(KeyCode::Char('H')));
16633 assert_eq!(selected_issue_id(&app), "A");
16634 }
16635
16636 #[test]
16637 fn graph_mode_h_from_detail_returns_to_list_focus() {
16638 let mut app = new_app(ViewMode::Graph, 0);
16639 assert_eq!(app.focus, FocusPane::List);
16640
16641 app.update(key(KeyCode::Tab));
16643 assert_eq!(app.focus, FocusPane::Detail);
16644
16645 app.update(key(KeyCode::Char('h')));
16647 assert_eq!(app.focus, FocusPane::List);
16648 assert!(matches!(app.mode, ViewMode::Graph));
16649
16650 app.update(key(KeyCode::Char('l')));
16652 assert_eq!(selected_issue_id(&app), "B");
16653 app.update(key(KeyCode::Char('h')));
16654 assert_eq!(selected_issue_id(&app), "A");
16655 }
16656
16657 #[test]
16658 fn main_mode_search_query_and_match_cycling_work() {
16659 let mut app = new_app(ViewMode::Main, 0);
16660 assert!(!app.main_search_active);
16661
16662 app.update(key(KeyCode::Char('/')));
16663 assert!(app.main_search_active);
16664 assert!(app.main_search_query.is_empty());
16665
16666 app.update(key(KeyCode::Char('d')));
16667 assert_eq!(app.main_search_query, "d");
16668 assert_eq!(selected_issue_id(&app), "A");
16669 assert!(app.list_panel_text().contains("hit 1/3"));
16670
16671 app.update(key(KeyCode::Enter));
16672 assert!(!app.main_search_active);
16673 assert_eq!(app.main_search_query, "d");
16674 assert!(app.list_panel_text().contains("Matches: 1/3"));
16675
16676 app.update(key(KeyCode::Char('n')));
16677 assert_eq!(selected_issue_id(&app), "B");
16678 assert!(app.list_panel_text().contains("Matches: 2/3"));
16679 assert!(app.list_panel_text().contains("hit 2/3"));
16680
16681 app.update(key(KeyCode::Char('N')));
16682 assert_eq!(selected_issue_id(&app), "A");
16683 assert!(app.list_panel_text().contains("Matches: 1/3"));
16684
16685 app.update(key(KeyCode::Char('/')));
16686 app.update(key(KeyCode::Char('z')));
16687 assert_eq!(app.main_search_query, "z");
16688 app.update(key(KeyCode::Escape));
16689 assert!(!app.main_search_active);
16690 assert!(app.main_search_query.is_empty());
16691 }
16692
16693 #[test]
16694 fn main_mode_search_no_match_message_is_explicit() {
16695 let mut app = new_app(ViewMode::Main, 0);
16696
16697 app.update(key(KeyCode::Char('/')));
16698 app.update(key(KeyCode::Char('z')));
16699
16700 let active = app.list_panel_text();
16701 assert!(active.contains("Search (active): /z"));
16702 assert!(active.contains("Matches: none"));
16703
16704 app.update(key(KeyCode::Enter));
16705 let finished = app.list_panel_text();
16706 assert!(finished.contains("Search: /z (n/N cycles)"));
16707 assert!(finished.contains("Matches: none"));
16708 assert_eq!(selected_issue_id(&app), "A");
16709 }
16710
16711 #[test]
16712 fn main_mode_search_requires_list_focus() {
16713 let mut app = new_app(ViewMode::Main, 0);
16714 app.focus = FocusPane::Detail;
16715
16716 app.update(key(KeyCode::Char('/')));
16717 assert!(!app.main_search_active);
16718
16719 app.update(key(KeyCode::Tab));
16720 assert_eq!(app.focus, FocusPane::List);
16721 app.update(key(KeyCode::Char('/')));
16722 assert!(app.main_search_active);
16723 }
16724
16725 #[test]
16726 fn keyflow_main_escape_unwinds_focus_search_and_filter() {
16727 let mut app = new_app(ViewMode::Main, 0);
16728
16729 app.update(key(KeyCode::Tab));
16730 assert_eq!(app.focus, FocusPane::Detail);
16731 app.update(key(KeyCode::Escape));
16732 assert_eq!(app.focus, FocusPane::List);
16733 assert_eq!(app.status_msg, "Focus returned to list");
16734
16735 app.update(key(KeyCode::Char('/')));
16736 app.update(key(KeyCode::Char('d')));
16737 app.update(key(KeyCode::Enter));
16738 assert_eq!(app.main_search_query, "d");
16739 app.update(key(KeyCode::Escape));
16740 assert!(app.main_search_query.is_empty());
16741 assert_eq!(app.status_msg, "Main search cleared");
16742
16743 app.update(key(KeyCode::Char('o')));
16744 assert_eq!(app.list_filter, ListFilter::Open);
16745 app.update(key(KeyCode::Escape));
16746 assert_eq!(app.list_filter, ListFilter::All);
16747 }
16748
16749 #[test]
16750 fn backtab_reverses_main_focus_cycle() {
16751 let mut app = new_app(ViewMode::Main, 0);
16752 assert_eq!(app.focus, FocusPane::List);
16753
16754 app.update(key_backtab());
16755 assert_eq!(app.focus, FocusPane::Detail);
16756
16757 app.update(key_backtab());
16758 assert_eq!(app.focus, FocusPane::List);
16759 }
16760
16761 #[test]
16762 fn backtab_reverses_history_focus_cycle_with_file_tree() {
16763 let mut app = new_app(ViewMode::History, 0);
16764 app.history_show_file_tree = true;
16765
16766 app.update(key_backtab());
16767 assert!(app.history_file_tree_focus);
16768 assert_eq!(app.focus, FocusPane::List);
16769
16770 app.update(key_backtab());
16771 assert!(!app.history_file_tree_focus);
16772 assert_eq!(app.focus, FocusPane::Detail);
16773
16774 app.update(key_backtab());
16775 assert_eq!(app.focus, FocusPane::List);
16776 }
16777
16778 #[test]
16779 fn main_list_focus_banner_tracks_active_pane() {
16780 let mut app = new_app(ViewMode::Main, 0);
16781 let list_focus = app.main_list_render_text(90).to_plain_text();
16782 assert!(list_focus.contains("Focus: list owns selection"));
16783 assert!(list_focus.contains("scope=all"));
16784
16785 app.focus = FocusPane::Detail;
16786 let detail_focus = app.main_list_render_text(90).to_plain_text();
16787 assert!(detail_focus.contains("Focus: detail owns J/K deps"));
16788 assert!(detail_focus.contains("selected=A"));
16789 }
16790
16791 #[test]
16792 fn main_list_scope_banner_surfaces_label_repo_and_search_state() {
16793 let mut app = new_app(ViewMode::Main, 0);
16794 app.modal_label_filter = Some("core".to_string());
16795 app.modal_repo_filter = Some("viewer".to_string());
16796 app.main_search_query = "root".to_string();
16797
16798 let text = app.main_list_render_text(100).to_plain_text();
16799 assert!(text.contains("label=core"), "label scope missing: {text}");
16800 assert!(text.contains("pos=1/1"), "position scope missing: {text}");
16801 assert!(text.contains("repo=viewer"), "repo scope missing: {text}");
16802 assert!(text.contains("search=root"), "search scope missing: {text}");
16803 }
16804
16805 #[test]
16806 fn page_down_uses_viewport_aware_step_in_main_mode() {
16807 let issues = (0..20)
16808 .map(|idx| Issue {
16809 id: format!("ISSUE-{idx:02}"),
16810 title: format!("Issue {idx:02}"),
16811 status: "open".to_string(),
16812 issue_type: "task".to_string(),
16813 priority: idx % 4,
16814 ..Issue::default()
16815 })
16816 .collect();
16817 let mut app = new_app_with_issues(ViewMode::Main, 0, issues);
16818 let _ = render_app(&app, 120, 16);
16819 let visible = app.visible_issue_indices_for_list_nav();
16820 let page_step = app.list_page_step();
16821 let expected_down = visible[page_step.min(visible.len().saturating_sub(1))];
16822
16823 app.update(key(KeyCode::PageDown));
16824 assert_eq!(app.selected, expected_down);
16825
16826 app.update(key(KeyCode::PageUp));
16827 assert_eq!(selected_issue_id(&app), "ISSUE-00");
16828 }
16829
16830 #[test]
16831 fn graph_mode_list_header_shows_keybinding_hints() {
16832 let app = new_app(ViewMode::Graph, 0);
16833 let list_text = app.list_panel_text();
16834 assert!(list_text.contains("h/l nav"));
16835 assert!(list_text.contains("Tab focus"));
16836 assert!(list_text.contains("/ search"));
16837 }
16838
16839 #[test]
16840 fn graph_mode_search_query_and_match_cycling_work() {
16841 let issues = vec![
16842 Issue {
16843 id: "B".to_string(),
16844 title: "Alpha dependent".to_string(),
16845 status: "open".to_string(),
16846 issue_type: "task".to_string(),
16847 dependencies: vec![Dependency {
16848 issue_id: "B".to_string(),
16849 depends_on_id: "A".to_string(),
16850 dep_type: "blocks".to_string(),
16851 ..Dependency::default()
16852 }],
16853 ..Issue::default()
16854 },
16855 Issue {
16856 id: "A".to_string(),
16857 title: "Alpha root".to_string(),
16858 status: "open".to_string(),
16859 issue_type: "task".to_string(),
16860 ..Issue::default()
16861 },
16862 ];
16863 let mut app = new_app_with_issues(ViewMode::Graph, 0, issues);
16864 assert!(!app.graph_search_active);
16865
16866 app.update(key(KeyCode::Char('/')));
16867 assert!(app.graph_search_active);
16868 assert!(app.graph_search_query.is_empty());
16869
16870 app.update(key(KeyCode::Char('a')));
16872 assert_eq!(app.graph_search_query, "a");
16873 assert_eq!(selected_issue_id(&app), "A");
16874 assert!(app.list_panel_text().contains("hit 1/2"));
16875
16876 app.update(key(KeyCode::Enter));
16878 assert!(!app.graph_search_active);
16879 assert_eq!(app.graph_search_query, "a");
16880 assert!(app.list_panel_text().contains("Matches: 1/2"));
16881 assert!(app.list_panel_text().contains("hit 1/2"));
16882
16883 app.update(key(KeyCode::Char('n')));
16885 assert_eq!(selected_issue_id(&app), "B");
16886 assert!(app.list_panel_text().contains("Matches: 2/2"));
16887 assert!(app.list_panel_text().contains("hit 2/2"));
16888
16889 app.update(key(KeyCode::Char('N')));
16890 assert_eq!(selected_issue_id(&app), "A");
16891 assert!(app.list_panel_text().contains("Matches: 1/2"));
16892
16893 app.update(key(KeyCode::Char('/')));
16895 app.update(key(KeyCode::Char('x')));
16896 assert_eq!(app.graph_search_query, "x");
16897 app.update(key(KeyCode::Escape));
16898 assert!(!app.graph_search_active);
16899 assert!(app.graph_search_query.is_empty());
16900 }
16901
16902 #[test]
16903 fn graph_mode_search_starts_from_detail_focus_and_returns_to_list() {
16904 let mut app = new_app(ViewMode::Graph, 0);
16905 app.focus = FocusPane::Detail;
16906
16907 app.update(key(KeyCode::Char('/')));
16908
16909 assert!(app.graph_search_active);
16910 assert_eq!(app.focus, FocusPane::List);
16911 assert!(app.graph_search_query.is_empty());
16912 }
16913
16914 #[test]
16915 fn graph_mode_search_prefers_rendered_order_over_storage_order() {
16916 let issues = vec![
16917 Issue {
16918 id: "B".to_string(),
16919 title: "Alpha dependent".to_string(),
16920 status: "open".to_string(),
16921 issue_type: "task".to_string(),
16922 dependencies: vec![Dependency {
16923 issue_id: "B".to_string(),
16924 depends_on_id: "A".to_string(),
16925 dep_type: "blocks".to_string(),
16926 ..Dependency::default()
16927 }],
16928 ..Issue::default()
16929 },
16930 Issue {
16931 id: "A".to_string(),
16932 title: "Alpha root".to_string(),
16933 status: "open".to_string(),
16934 issue_type: "task".to_string(),
16935 ..Issue::default()
16936 },
16937 ];
16938 let mut app = new_app_with_issues(ViewMode::Graph, 0, issues);
16939
16940 let rendered_ids = app
16941 .graph_visible_issue_indices()
16942 .into_iter()
16943 .map(|index| app.analyzer.issues[index].id.clone())
16944 .collect::<Vec<_>>();
16945 assert_eq!(rendered_ids, vec!["A", "B"]);
16946
16947 app.update(key(KeyCode::Char('/')));
16948 app.update(key(KeyCode::Char('a')));
16949
16950 assert_eq!(selected_issue_id(&app), "A");
16951 assert!(
16952 app.list_panel_text()
16953 .lines()
16954 .any(|line| line.starts_with('>') && line.contains(" A"))
16955 );
16956 }
16957
16958 #[test]
16959 fn graph_mode_navigation_uses_graph_ranked_order() {
16960 let issues = vec![
16961 Issue {
16962 id: "B".to_string(),
16963 title: "Dependent".to_string(),
16964 status: "open".to_string(),
16965 issue_type: "task".to_string(),
16966 dependencies: vec![Dependency {
16967 issue_id: "B".to_string(),
16968 depends_on_id: "A".to_string(),
16969 dep_type: "blocks".to_string(),
16970 ..Dependency::default()
16971 }],
16972 ..Issue::default()
16973 },
16974 Issue {
16975 id: "A".to_string(),
16976 title: "Root".to_string(),
16977 status: "open".to_string(),
16978 issue_type: "task".to_string(),
16979 ..Issue::default()
16980 },
16981 ];
16982 let mut app = new_app_with_issues(ViewMode::Graph, 0, issues);
16983 let order = app
16984 .graph_visible_issue_indices()
16985 .into_iter()
16986 .map(|index| app.analyzer.issues[index].id.clone())
16987 .collect::<Vec<_>>();
16988 assert_eq!(order.len(), 2);
16989
16990 assert_eq!(selected_issue_id(&app), order[0]);
16991 app.update(key(KeyCode::Char('j')));
16992 assert_eq!(selected_issue_id(&app), order[1]);
16993 app.update(key(KeyCode::Char('k')));
16994 assert_eq!(selected_issue_id(&app), order[0]);
16995 }
16996
16997 #[test]
16998 fn graph_mode_toggle_reselects_first_graph_ranked_issue() {
16999 let issues = vec![
17000 Issue {
17001 id: "B".to_string(),
17002 title: "Dependent".to_string(),
17003 status: "open".to_string(),
17004 issue_type: "task".to_string(),
17005 dependencies: vec![Dependency {
17006 issue_id: "B".to_string(),
17007 depends_on_id: "A".to_string(),
17008 dep_type: "blocks".to_string(),
17009 ..Dependency::default()
17010 }],
17011 ..Issue::default()
17012 },
17013 Issue {
17014 id: "A".to_string(),
17015 title: "Root".to_string(),
17016 status: "open".to_string(),
17017 issue_type: "task".to_string(),
17018 ..Issue::default()
17019 },
17020 ];
17021 let mut app = new_app_with_issues(ViewMode::Main, 0, issues);
17022 let graph_order = app.graph_visible_issue_indices();
17023 assert_eq!(graph_order.len(), 2);
17024 app.set_selected_index(graph_order[1]);
17025 let expected = app.analyzer.issues[graph_order[0]].id.clone();
17026 app.update(key(KeyCode::Char('g')));
17027 assert_eq!(app.mode, ViewMode::Graph);
17028 assert_eq!(selected_issue_id(&app), expected);
17029 }
17030
17031 #[test]
17032 fn graph_mode_toggle_preserves_search_match_selection() {
17033 let issues = vec![
17034 Issue {
17035 id: "B".to_string(),
17036 title: "Alpha dependent".to_string(),
17037 status: "open".to_string(),
17038 issue_type: "task".to_string(),
17039 dependencies: vec![Dependency {
17040 issue_id: "B".to_string(),
17041 depends_on_id: "A".to_string(),
17042 dep_type: "blocks".to_string(),
17043 ..Dependency::default()
17044 }],
17045 ..Issue::default()
17046 },
17047 Issue {
17048 id: "A".to_string(),
17049 title: "Root".to_string(),
17050 status: "open".to_string(),
17051 issue_type: "task".to_string(),
17052 ..Issue::default()
17053 },
17054 ];
17055 let mut app = new_app_with_issues(ViewMode::Graph, 0, issues);
17056 app.set_selected_index(app.issue_index_for_id("B").expect("issue B should exist"));
17057
17058 app.update(key(KeyCode::Char('/')));
17059 app.update(key(KeyCode::Char('a')));
17060 app.update(key(KeyCode::Enter));
17061 assert_eq!(selected_issue_id(&app), "B");
17062
17063 app.update(key(KeyCode::Char('g')));
17064 assert_eq!(app.mode, ViewMode::Main);
17065 app.update(key(KeyCode::F(20)));
17067 app.update(key(KeyCode::Char('g')));
17068 assert_eq!(app.mode, ViewMode::Graph);
17069 assert_eq!(selected_issue_id(&app), "B");
17070 }
17071
17072 #[test]
17073 fn insights_mode_search_query_and_match_cycling_work() {
17074 let issues = vec![
17075 Issue {
17076 id: "B".to_string(),
17077 title: "Alpha dependent".to_string(),
17078 status: "open".to_string(),
17079 issue_type: "task".to_string(),
17080 dependencies: vec![Dependency {
17081 issue_id: "B".to_string(),
17082 depends_on_id: "A".to_string(),
17083 dep_type: "blocks".to_string(),
17084 ..Dependency::default()
17085 }],
17086 ..Issue::default()
17087 },
17088 Issue {
17089 id: "A".to_string(),
17090 title: "Alpha root".to_string(),
17091 status: "open".to_string(),
17092 issue_type: "task".to_string(),
17093 ..Issue::default()
17094 },
17095 ];
17096 let mut app = new_app_with_issues(ViewMode::Main, 0, issues);
17097 app.update(key(KeyCode::Char('i')));
17098 assert!(matches!(app.mode, ViewMode::Insights));
17099 assert!(!app.insights_search_active);
17100
17101 app.update(key(KeyCode::Char('/')));
17102 assert!(app.insights_search_active);
17103 assert!(app.insights_search_query.is_empty());
17104
17105 app.update(key(KeyCode::Char('a')));
17107 assert_eq!(app.insights_search_query, "a");
17108 assert_eq!(selected_issue_id(&app), "A");
17109 assert!(app.list_panel_text().contains("hit 1/2"));
17110
17111 app.update(key(KeyCode::Enter));
17113 assert!(!app.insights_search_active);
17114 assert_eq!(app.insights_search_query, "a");
17115 assert!(app.list_panel_text().contains("Matches: 1/2"));
17116 assert!(app.list_panel_text().contains("hit 1/2"));
17117
17118 app.update(key(KeyCode::Char('n')));
17119 assert_eq!(selected_issue_id(&app), "B");
17120 assert!(app.list_panel_text().contains("Matches: 2/2"));
17121 assert!(app.list_panel_text().contains("hit 2/2"));
17122
17123 app.update(key(KeyCode::Char('N')));
17124 assert_eq!(selected_issue_id(&app), "A");
17125 assert!(app.list_panel_text().contains("Matches: 1/2"));
17126
17127 app.update(key(KeyCode::Char('/')));
17129 app.update(key(KeyCode::Char('z')));
17130 assert_eq!(app.insights_search_query, "z");
17131 app.update(key(KeyCode::Escape));
17132 assert!(!app.insights_search_active);
17133 assert!(app.insights_search_query.is_empty());
17134 }
17135
17136 #[test]
17137 fn insights_mode_search_starts_from_detail_focus_and_returns_to_list() {
17138 let mut app = new_app(ViewMode::Insights, 0);
17139 app.focus = FocusPane::Detail;
17140
17141 app.update(key(KeyCode::Char('/')));
17142
17143 assert!(app.insights_search_active);
17144 assert_eq!(app.focus, FocusPane::List);
17145 assert!(app.insights_search_query.is_empty());
17146 }
17147
17148 #[test]
17149 fn insights_mode_selection_tracks_bottleneck_order_not_storage_order() {
17150 let issues = vec![
17151 Issue {
17152 id: "C".to_string(),
17153 title: "Closed unrelated".to_string(),
17154 status: "closed".to_string(),
17155 issue_type: "task".to_string(),
17156 ..Issue::default()
17157 },
17158 Issue {
17159 id: "B".to_string(),
17160 title: "Dependent".to_string(),
17161 status: "open".to_string(),
17162 issue_type: "task".to_string(),
17163 dependencies: vec![Dependency {
17164 issue_id: "B".to_string(),
17165 depends_on_id: "A".to_string(),
17166 dep_type: "blocks".to_string(),
17167 ..Dependency::default()
17168 }],
17169 ..Issue::default()
17170 },
17171 Issue {
17172 id: "A".to_string(),
17173 title: "Root bottleneck".to_string(),
17174 status: "open".to_string(),
17175 issue_type: "task".to_string(),
17176 ..Issue::default()
17177 },
17178 ];
17179 let mut app = new_app_with_issues(ViewMode::Insights, 0, issues);
17180
17181 let expected = app
17182 .analyzer
17183 .insights()
17184 .bottlenecks
17185 .first()
17186 .map(|item| item.id.clone())
17187 .expect("bottleneck ranking");
17188 assert_eq!(selected_issue_id(&app), expected);
17189
17190 let order = app
17191 .insights_visible_issue_indices_for_list_nav()
17192 .into_iter()
17193 .map(|index| app.analyzer.issues[index].id.clone())
17194 .collect::<Vec<_>>();
17195 assert!(!order.is_empty());
17196
17197 app.update(key(KeyCode::Char('j')));
17198 let next_expected = order.get(1).cloned().unwrap_or_else(|| order[0].clone());
17199 assert_eq!(selected_issue_id(&app), next_expected);
17200 }
17201
17202 #[test]
17203 fn insights_mode_toggle_and_panel_cycle_sync_ranked_selection() {
17204 let issues = vec![
17205 Issue {
17206 id: "C".to_string(),
17207 title: "Closed unrelated".to_string(),
17208 status: "closed".to_string(),
17209 issue_type: "task".to_string(),
17210 ..Issue::default()
17211 },
17212 Issue {
17213 id: "B".to_string(),
17214 title: "Dependent".to_string(),
17215 status: "open".to_string(),
17216 issue_type: "task".to_string(),
17217 dependencies: vec![Dependency {
17218 issue_id: "B".to_string(),
17219 depends_on_id: "A".to_string(),
17220 dep_type: "blocks".to_string(),
17221 ..Dependency::default()
17222 }],
17223 ..Issue::default()
17224 },
17225 Issue {
17226 id: "A".to_string(),
17227 title: "Root bottleneck".to_string(),
17228 status: "open".to_string(),
17229 issue_type: "task".to_string(),
17230 ..Issue::default()
17231 },
17232 ];
17233 let mut app = new_app_with_issues(ViewMode::Main, 0, issues);
17234 app.set_selected_index(0);
17235
17236 app.update(key(KeyCode::Char('i')));
17237 assert_eq!(app.mode, ViewMode::Insights);
17238 let bottleneck_expected = app
17239 .insights_visible_issue_indices_for_list_nav()
17240 .first()
17241 .map(|index| app.analyzer.issues[*index].id.clone())
17242 .expect("bottleneck ranked issue");
17243 assert_eq!(selected_issue_id(&app), bottleneck_expected);
17244
17245 let bottleneck_order = app.insights_visible_issue_indices_for_list_nav();
17246 if let Some(second_index) = bottleneck_order.get(1).copied() {
17247 app.set_selected_index(second_index);
17248 }
17249
17250 app.update(key(KeyCode::Char('s')));
17251 assert_eq!(app.insights_panel, InsightsPanel::Keystones);
17252 let keystone_expected = app
17253 .insights_visible_issue_indices_for_list_nav()
17254 .first()
17255 .map(|index| app.analyzer.issues[*index].id.clone())
17256 .expect("keystone ranked issue");
17257 assert_eq!(selected_issue_id(&app), keystone_expected);
17258 }
17259
17260 #[test]
17261 fn insights_panel_cycle_preserves_search_match_selection() {
17262 let issues = vec![
17263 Issue {
17264 id: "C".to_string(),
17265 title: "Closed unrelated".to_string(),
17266 status: "closed".to_string(),
17267 issue_type: "task".to_string(),
17268 ..Issue::default()
17269 },
17270 Issue {
17271 id: "B".to_string(),
17272 title: "Alpha dependent".to_string(),
17273 status: "open".to_string(),
17274 issue_type: "task".to_string(),
17275 dependencies: vec![Dependency {
17276 issue_id: "B".to_string(),
17277 depends_on_id: "A".to_string(),
17278 dep_type: "blocks".to_string(),
17279 ..Dependency::default()
17280 }],
17281 ..Issue::default()
17282 },
17283 Issue {
17284 id: "A".to_string(),
17285 title: "Root bottleneck".to_string(),
17286 status: "open".to_string(),
17287 issue_type: "task".to_string(),
17288 ..Issue::default()
17289 },
17290 ];
17291 let mut app = new_app_with_issues(ViewMode::Insights, 0, issues);
17292 app.set_selected_index(app.issue_index_for_id("B").expect("issue B should exist"));
17293
17294 app.update(key(KeyCode::Char('/')));
17295 app.update(key(KeyCode::Char('a')));
17296 app.update(key(KeyCode::Enter));
17297 assert_eq!(selected_issue_id(&app), "B");
17298
17299 app.update(key(KeyCode::Char('s')));
17300 assert_eq!(app.insights_panel, InsightsPanel::Keystones);
17301 assert_eq!(selected_issue_id(&app), "B");
17302 }
17303
17304 #[test]
17305 fn insights_entry_from_graph_preserves_external_context_issue() {
17306 let issues = vec![
17307 Issue {
17308 id: "C".to_string(),
17309 title: "Closed unrelated".to_string(),
17310 status: "closed".to_string(),
17311 issue_type: "task".to_string(),
17312 ..Issue::default()
17313 },
17314 Issue {
17315 id: "B".to_string(),
17316 title: "Dependent".to_string(),
17317 status: "open".to_string(),
17318 issue_type: "task".to_string(),
17319 dependencies: vec![Dependency {
17320 issue_id: "B".to_string(),
17321 depends_on_id: "A".to_string(),
17322 dep_type: "blocks".to_string(),
17323 ..Dependency::default()
17324 }],
17325 ..Issue::default()
17326 },
17327 Issue {
17328 id: "A".to_string(),
17329 title: "Root bottleneck".to_string(),
17330 status: "open".to_string(),
17331 issue_type: "task".to_string(),
17332 ..Issue::default()
17333 },
17334 ];
17335 let mut app = new_app_with_issues(ViewMode::Board, 0, issues);
17336 app.set_selected_index(app.issue_index_for_id("C").expect("issue C should exist"));
17337
17338 app.update(key(KeyCode::Char('g')));
17339 assert_eq!(app.mode, ViewMode::Graph);
17340 assert_eq!(selected_issue_id(&app), "C");
17341
17342 app.update(key(KeyCode::Char('i')));
17343 assert_eq!(app.mode, ViewMode::Insights);
17344 assert_eq!(selected_issue_id(&app), "C");
17345
17346 app.update(key(KeyCode::Char('s')));
17347 assert_eq!(app.insights_panel, InsightsPanel::Keystones);
17348 assert_eq!(selected_issue_id(&app), "C");
17349
17350 app.update(key(KeyCode::Escape));
17351 assert_eq!(app.mode, ViewMode::Main);
17352 assert_eq!(selected_issue_id(&app), "C");
17353 }
17354
17355 #[test]
17356 fn graph_entry_from_insights_preserves_external_context_issue() {
17357 let issues = vec![
17358 Issue {
17359 id: "C".to_string(),
17360 title: "Closed unrelated".to_string(),
17361 status: "closed".to_string(),
17362 issue_type: "task".to_string(),
17363 ..Issue::default()
17364 },
17365 Issue {
17366 id: "B".to_string(),
17367 title: "Dependent".to_string(),
17368 status: "open".to_string(),
17369 issue_type: "task".to_string(),
17370 dependencies: vec![Dependency {
17371 issue_id: "B".to_string(),
17372 depends_on_id: "A".to_string(),
17373 dep_type: "blocks".to_string(),
17374 ..Dependency::default()
17375 }],
17376 ..Issue::default()
17377 },
17378 Issue {
17379 id: "A".to_string(),
17380 title: "Root bottleneck".to_string(),
17381 status: "open".to_string(),
17382 issue_type: "task".to_string(),
17383 ..Issue::default()
17384 },
17385 ];
17386 let mut app = new_app_with_issues(ViewMode::Board, 0, issues);
17387 app.set_selected_index(app.issue_index_for_id("C").expect("issue C should exist"));
17388
17389 app.update(key(KeyCode::Char('i')));
17390 assert_eq!(app.mode, ViewMode::Insights);
17391 assert_eq!(selected_issue_id(&app), "C");
17392
17393 app.update(key(KeyCode::Char('g')));
17394 assert_eq!(app.mode, ViewMode::Graph);
17395 assert_eq!(selected_issue_id(&app), "C");
17396
17397 app.update(key(KeyCode::Escape));
17398 assert_eq!(app.mode, ViewMode::Main);
17399 assert_eq!(selected_issue_id(&app), "C");
17400 }
17401
17402 #[test]
17403 fn insights_list_header_shows_search_hint() {
17404 let mut app = new_app(ViewMode::Main, 0);
17405 app.update(key(KeyCode::Char('i')));
17406 let list_text = app.list_panel_text();
17407 assert!(list_text.contains("/ search"));
17408 }
17409
17410 #[test]
17411 fn history_confidence_cycles_on_c_key() {
17412 let mut app = new_app(ViewMode::Main, 0);
17413 app.update(key(KeyCode::Char('h')));
17414
17415 let initial_index = app.history_confidence_index;
17416 app.update(key(KeyCode::Char('c')));
17417 assert_ne!(app.history_confidence_index, initial_index);
17418 }
17419
17420 #[test]
17421 fn history_git_mode_c_cycles_confidence_and_clamps_cursor() {
17422 let mut app = history_app_with_git_cache(HistoryViewMode::Git, 0);
17423 app.mode = ViewMode::History;
17424 app.history_view_mode = HistoryViewMode::Git;
17425 app.history_event_cursor = 3;
17426 app.history_file_tree_cursor = 99;
17427
17428 app.update(key(KeyCode::Char('c')));
17429 app.update(key(KeyCode::Char('c')));
17430 app.update(key(KeyCode::Char('c')));
17431
17432 assert_eq!(app.history_confidence_index, 3);
17433 assert_eq!(app.history_git_visible_commit_indices(), vec![0, 2]);
17434 assert_eq!(app.history_event_cursor, 1);
17435 assert!(app.history_flat_file_list().len() < 99);
17436 assert_eq!(app.history_file_tree_cursor, 4);
17437 }
17438
17439 #[test]
17440 fn history_v_toggles_git_mode_and_enter_jumps_to_related_issue() {
17441 let mut app = new_app(ViewMode::Main, 0);
17442 app.update(key(KeyCode::Char('h')));
17443 assert!(matches!(app.history_view_mode, HistoryViewMode::Bead));
17444
17445 app.update(key(KeyCode::Char('v')));
17446 assert!(matches!(app.history_view_mode, HistoryViewMode::Git));
17447 let git_list = app.list_panel_text();
17448 assert!(git_list.contains("Git commits") || git_list.contains("No git commits correlated"));
17449
17450 assert!(
17451 app.selected_history_event().is_some(),
17452 "git timeline should contain at least one event"
17453 );
17454
17455 app.update(key(KeyCode::Char('j')));
17456 app.update(key(KeyCode::Char('k')));
17457 assert!(matches!(app.history_view_mode, HistoryViewMode::Git));
17458
17459 app.update(key(KeyCode::Char('c')));
17460 assert_eq!(app.history_confidence_index, 1);
17461
17462 let expected_issue_id = app
17464 .selected_history_git_related_bead_id()
17465 .or_else(|| app.selected_history_event().map(|event| event.issue_id))
17466 .expect("should have a related bead after confidence change");
17467
17468 let cmd = app.update(key(KeyCode::Enter));
17469 assert!(matches!(cmd, Cmd::None));
17470 assert!(matches!(app.mode, ViewMode::Main));
17471 assert_eq!(app.focus, FocusPane::Detail);
17472 assert_eq!(selected_issue_id(&app), expected_issue_id);
17473 }
17474
17475 #[test]
17476 fn history_reentry_resets_search_and_file_tree_state() {
17477 let mut app = history_app_with_git_cache(HistoryViewMode::Git, 0);
17478 app.mode = ViewMode::History;
17479 app.mode_before_history = ViewMode::Main;
17480 app.history_view_mode = HistoryViewMode::Git;
17481 app.history_search_active = false;
17482 app.history_search_query = "graph".to_string();
17483 app.history_search_match_cursor = 2;
17484 app.history_search_mode = HistorySearchMode::Author;
17485 app.history_show_file_tree = true;
17486 app.history_file_tree_cursor = 3;
17487 app.history_file_tree_filter = Some("src/ui".to_string());
17488 app.history_file_tree_focus = true;
17489 app.history_status_msg = "Filtered to: src/ui".to_string();
17490 app.focus = FocusPane::Detail;
17491
17492 app.update(key(KeyCode::Char('q')));
17493 assert!(matches!(app.mode, ViewMode::Main));
17494
17495 app.update(key(KeyCode::Char('h')));
17496 assert!(matches!(app.mode, ViewMode::History));
17497 assert!(matches!(app.history_view_mode, HistoryViewMode::Bead));
17498 assert!(!app.history_search_active);
17499 assert!(app.history_search_query.is_empty());
17500 assert_eq!(app.history_search_match_cursor, 0);
17501 assert_eq!(app.history_search_mode, HistorySearchMode::All);
17502 assert!(!app.history_show_file_tree);
17503 assert_eq!(app.history_file_tree_cursor, 0);
17504 assert!(app.history_file_tree_filter.is_none());
17505 assert!(!app.history_file_tree_focus);
17506 assert!(app.history_status_msg.is_empty());
17507 assert_eq!(app.focus, FocusPane::List);
17508 }
17509
17510 #[test]
17511 fn history_git_mode_shift_j_k_perform_secondary_navigation() {
17512 let mut app = new_app(ViewMode::Main, 0);
17513 app.update(key(KeyCode::Char('h')));
17514 app.update(key(KeyCode::Char('v')));
17515 assert!(matches!(app.history_view_mode, HistoryViewMode::Git));
17516 assert_eq!(app.history_related_bead_cursor, 0);
17517 assert_eq!(app.history_event_cursor, 0);
17518
17519 app.update(key(KeyCode::Char('J')));
17522 app.update(key(KeyCode::Char('K')));
17523
17524 assert_eq!(app.history_related_bead_cursor, 0);
17526 assert_eq!(app.history_event_cursor, 0);
17527 }
17528
17529 #[test]
17530 fn history_mode_search_filters_git_timeline_and_intercepts_hotkeys() {
17531 let mut app = new_app(ViewMode::Main, 0);
17532 app.update(key(KeyCode::Char('h')));
17533 app.update(key(KeyCode::Char('v')));
17534 assert!(matches!(app.mode, ViewMode::History));
17535 assert!(matches!(app.history_view_mode, HistoryViewMode::Git));
17536
17537 app.update(key(KeyCode::Char('/')));
17538 assert!(app.history_search_active);
17539 assert!(app.history_search_query.is_empty());
17540
17541 app.update(key(KeyCode::Char('o')));
17542 assert_eq!(app.history_search_query, "o");
17543 assert_eq!(app.list_filter, ListFilter::All);
17544
17545 app.update(key(KeyCode::Backspace));
17546 assert!(app.history_search_query.is_empty());
17547
17548 for ch in "dependent".chars() {
17549 app.update(key(KeyCode::Char(ch)));
17550 }
17551 assert_eq!(app.history_search_query, "dependent");
17552 let event = app
17553 .selected_history_event()
17554 .expect("history git mode should have timeline events");
17555 assert_eq!(event.issue_id, "B");
17556
17557 app.update(key(KeyCode::Enter));
17558 assert!(!app.history_search_active);
17559 assert_eq!(app.history_search_query, "dependent");
17560
17561 app.update(key(KeyCode::Char('/')));
17562 app.update(key(KeyCode::Char('x')));
17563 assert_eq!(app.history_search_query, "x");
17564 app.update(key(KeyCode::Escape));
17565 assert!(matches!(app.mode, ViewMode::History));
17566 assert!(!app.history_search_active);
17567 assert!(app.history_search_query.is_empty());
17568 }
17569
17570 #[test]
17571 fn history_mode_search_filters_bead_list_and_escape_clears_query() {
17572 let mut app = new_app(ViewMode::Main, 0);
17573 app.update(key(KeyCode::Char('h')));
17574 assert!(matches!(app.mode, ViewMode::History));
17575 assert!(matches!(app.history_view_mode, HistoryViewMode::Bead));
17576
17577 app.update(key(KeyCode::Char('/')));
17578 for ch in "closed".chars() {
17579 app.update(key(KeyCode::Char(ch)));
17580 }
17581 assert_eq!(app.history_search_query, "closed");
17582 assert_eq!(selected_issue_id(&app), "C");
17583
17584 app.update(key(KeyCode::Enter));
17585 assert!(!app.history_search_active);
17586 assert_eq!(app.history_search_query, "closed");
17587
17588 app.update(key(KeyCode::Char('j')));
17589 assert_eq!(selected_issue_id(&app), "C");
17590
17591 app.update(key(KeyCode::Char('/')));
17592 app.update(key(KeyCode::Escape));
17593 assert!(!app.history_search_active);
17594 assert!(app.history_search_query.is_empty());
17595
17596 app.update(key(KeyCode::Home));
17597 assert_eq!(selected_issue_id(&app), "A");
17598 app.update(key(KeyCode::Char('j')));
17599 assert_eq!(selected_issue_id(&app), "B");
17600 }
17601
17602 #[test]
17603 fn history_mode_search_zero_results_show_explicit_message() {
17604 let mut app = new_app(ViewMode::Main, 0);
17605 app.update(key(KeyCode::Char('h')));
17606 assert!(matches!(app.mode, ViewMode::History));
17607 assert!(matches!(app.history_view_mode, HistoryViewMode::Bead));
17608
17609 app.update(key(KeyCode::Char('/')));
17610 for ch in "zzzz".chars() {
17611 app.update(key(KeyCode::Char(ch)));
17612 }
17613
17614 assert!(app.history_visible_issue_indices().is_empty());
17615 let text = app.history_list_text();
17616 assert!(text.contains("(no issues match history search: /zzzz)"));
17617
17618 app.update(key(KeyCode::Enter));
17619 assert!(!app.history_search_active);
17620 assert_eq!(app.history_search_query, "zzzz");
17621 assert!(
17622 app.history_list_text()
17623 .contains("(no issues match history search: /zzzz)")
17624 );
17625 }
17626
17627 #[test]
17628 fn history_git_search_zero_results_show_explicit_message() {
17629 let mut app = new_app(ViewMode::Main, 0);
17630 app.update(key(KeyCode::Char('h')));
17631 app.update(key(KeyCode::Char('v')));
17632 assert!(matches!(app.mode, ViewMode::History));
17633 assert!(matches!(app.history_view_mode, HistoryViewMode::Git));
17634
17635 app.update(key(KeyCode::Char('/')));
17636 for ch in "zzzz".chars() {
17637 app.update(key(KeyCode::Char(ch)));
17638 }
17639
17640 assert!(app.history_search_matches().is_empty());
17641 let text = app.history_list_text();
17642 assert!(text.contains("(no commits match search: /zzzz)"));
17643
17644 app.update(key(KeyCode::Enter));
17645 assert!(!app.history_search_active);
17646 assert_eq!(app.history_search_query, "zzzz");
17647 assert!(
17648 app.history_list_text()
17649 .contains("(no commits match search: /zzzz)")
17650 );
17651 }
17652
17653 #[test]
17654 fn history_git_mode_g_switches_to_graph_and_selects_issue_from_event() {
17655 let mut app = new_app(ViewMode::Main, 0);
17656 app.update(key(KeyCode::Char('h')));
17657 app.update(key(KeyCode::Char('v')));
17658 assert!(matches!(app.mode, ViewMode::History));
17659 assert!(matches!(app.history_view_mode, HistoryViewMode::Git));
17660
17661 let event_issue_id = app
17662 .selected_history_event()
17663 .expect("git timeline should have events")
17664 .issue_id;
17665
17666 app.update(key(KeyCode::Char('g')));
17667 assert!(matches!(app.mode, ViewMode::Graph));
17668 assert_eq!(selected_issue_id(&app), event_issue_id);
17669 }
17670
17671 #[test]
17672 fn enter_from_specialized_modes_returns_to_main_detail() {
17673 for mode in [
17674 ViewMode::Board,
17675 ViewMode::Insights,
17676 ViewMode::Graph,
17677 ViewMode::History,
17678 ] {
17679 let mut app = new_app(mode, 0);
17680 let cmd = app.update(key(KeyCode::Enter));
17681 assert!(matches!(cmd, Cmd::None));
17682 assert!(matches!(app.mode, ViewMode::Main));
17683 assert_eq!(app.focus, FocusPane::Detail);
17684 }
17685 }
17686
17687 #[test]
17688 fn filter_hotkeys_apply_and_escape_clears_before_quit_confirm() {
17689 let mut app = new_app(ViewMode::Main, 0);
17690
17691 app.update(key(KeyCode::Char('c')));
17692 assert_eq!(app.list_filter, ListFilter::Closed);
17693 assert_eq!(selected_issue_id(&app), "C");
17694
17695 let cmd = app.update(key(KeyCode::Escape));
17696 assert!(matches!(cmd, Cmd::None));
17697 assert_eq!(app.list_filter, ListFilter::All);
17698 assert!(!app.show_quit_confirm);
17699
17700 let cmd = app.update(key(KeyCode::Escape));
17701 assert!(matches!(cmd, Cmd::None));
17702 assert!(app.show_quit_confirm);
17703 }
17704
17705 #[test]
17706 fn filter_hotkeys_include_blocked_and_in_progress_slices() {
17707 let mut app = new_app_with_issues(ViewMode::Main, 0, lane_issues());
17708
17709 app.update(key(KeyCode::Char('I')));
17710 assert_eq!(app.list_filter, ListFilter::InProgress);
17711 assert_eq!(selected_issue_id(&app), "IP-1");
17712 assert_eq!(app.visible_issue_indices().len(), 1);
17713
17714 app.update(key(KeyCode::Char('B')));
17715 assert_eq!(app.list_filter, ListFilter::Blocked);
17716 assert_eq!(selected_issue_id(&app), "BLK-1");
17717 assert_eq!(app.visible_issue_indices().len(), 1);
17718
17719 app.update(key(KeyCode::Escape));
17720 assert_eq!(app.list_filter, ListFilter::All);
17721 assert!(!app.show_quit_confirm);
17722 }
17723
17724 #[test]
17725 fn list_navigation_respects_active_filter() {
17726 let mut app = new_app(ViewMode::Main, 0);
17727 app.update(key(KeyCode::Char('o')));
17728 assert_eq!(app.list_filter, ListFilter::Open);
17729 assert_eq!(selected_issue_id(&app), "A");
17730
17731 app.update(key(KeyCode::Char('j')));
17732 assert_eq!(selected_issue_id(&app), "B");
17733
17734 app.update(key(KeyCode::Char('j')));
17735 assert_eq!(selected_issue_id(&app), "B");
17736 }
17737
17738 #[test]
17739 fn board_mode_number_keys_jump_to_expected_lane_selection() {
17740 let mut app = BvrApp {
17741 analyzer: Analyzer::new(lane_issues()),
17742 repo_root: None,
17743 selected: 0,
17744 list_filter: ListFilter::All,
17745 list_sort: ListSort::Default,
17746 board_grouping: BoardGrouping::Status,
17747 board_empty_visibility: EmptyLaneVisibility::Auto,
17748 mode: ViewMode::Board,
17749 mode_before_history: ViewMode::Main,
17750 mode_back_stack: Vec::new(),
17751 focus: FocusPane::List,
17752 focus_before_help: FocusPane::List,
17753 show_help: false,
17754 help_scroll_offset: 0,
17755 show_quit_confirm: false,
17756 modal_overlay: None,
17757 modal_confirm_result: None,
17758 history_confidence_index: 0,
17759 history_view_mode: HistoryViewMode::Bead,
17760 history_event_cursor: 0,
17761 history_related_bead_cursor: 0,
17762 history_bead_commit_cursor: 0,
17763 history_git_cache: None,
17764 history_search_active: false,
17765 history_search_query: String::new(),
17766 history_search_match_cursor: 0,
17767 history_search_mode: HistorySearchMode::All,
17768 history_show_file_tree: false,
17769 history_file_tree_cursor: 0,
17770 history_file_tree_filter: None,
17771 history_file_tree_focus: false,
17772 history_status_msg: String::new(),
17773 board_search_active: false,
17774 board_search_query: String::new(),
17775 board_search_match_cursor: 0,
17776 board_detail_scroll_offset: 0,
17777 detail_scroll_offset: 0,
17778 main_search_active: false,
17779 main_search_query: String::new(),
17780 main_search_match_cursor: 0,
17781 list_scroll_offset: Cell::new(0),
17782 list_viewport_height: Cell::new(0),
17783 graph_search_active: false,
17784 graph_search_query: String::new(),
17785 graph_search_match_cursor: 0,
17786 insights_search_active: false,
17787 insights_search_query: String::new(),
17788 insights_search_match_cursor: 0,
17789 insights_panel: InsightsPanel::Bottlenecks,
17790 insights_heatmap: None,
17791 insights_show_explanations: true,
17792 insights_show_calc_proof: false,
17793 detail_dep_cursor: 0,
17794 actionable_plan: None,
17795 actionable_track_cursor: 0,
17796 actionable_item_cursor: 0,
17797 attention_result: None,
17798 attention_cursor: 0,
17799 tree_flat_nodes: Vec::new(),
17800 tree_cursor: 0,
17801 tree_collapsed: std::collections::HashSet::new(),
17802 tree_search_active: false,
17803 tree_search_query: String::new(),
17804 tree_search_match_cursor: 0,
17805 pending_g: false,
17806 g_pre_toggle_mode: None,
17807 pending_z: false,
17808 label_dashboard: None,
17809 label_dashboard_cursor: 0,
17810 flow_matrix: None,
17811 flow_matrix_row_cursor: 0,
17812 flow_matrix_col_cursor: 0,
17813 time_travel_ref_input: String::new(),
17814 time_travel_input_active: false,
17815 time_travel_diff: None,
17816 time_travel_category_cursor: 0,
17817 time_travel_issue_cursor: 0,
17818 time_travel_last_ref: None,
17819 sprint_data: Vec::new(),
17820 sprint_cursor: 0,
17821 sprint_issue_cursor: 0,
17822 modal_label_filter: None,
17823 modal_repo_filter: None,
17824 priority_hints_visible: false,
17825 status_msg: String::new(),
17826 slow_metrics_pending: false,
17827 #[cfg(test)]
17828 key_trace: Vec::new(),
17829 };
17830
17831 app.update(key(KeyCode::Char('2')));
17832 assert_eq!(selected_issue_id(&app), "IP-1");
17833 assert!(matches!(app.mode, ViewMode::Board));
17834
17835 app.update(key(KeyCode::Char('3')));
17836 assert_eq!(selected_issue_id(&app), "BLK-1");
17837
17838 app.update(key(KeyCode::Char('4')));
17839 assert_eq!(selected_issue_id(&app), "CLS-1");
17840
17841 app.update(key(KeyCode::Char('1')));
17842 app.select_issue_by_id("OPEN-1");
17843 app.select_issue_by_id("OPEN-1");
17844 assert_eq!(selected_issue_id(&app), "OPEN-1");
17845 assert!(matches!(app.mode, ViewMode::Board));
17846 }
17847
17848 #[test]
17849 fn board_grouping_cycles_and_lane_jumps_follow_grouping() {
17850 let mut app = BvrApp {
17851 analyzer: Analyzer::new(lane_issues()),
17852 repo_root: None,
17853 selected: 0,
17854 list_filter: ListFilter::All,
17855 list_sort: ListSort::Default,
17856 board_grouping: BoardGrouping::Status,
17857 board_empty_visibility: EmptyLaneVisibility::Auto,
17858 mode: ViewMode::Board,
17859 mode_before_history: ViewMode::Main,
17860 mode_back_stack: Vec::new(),
17861 focus: FocusPane::List,
17862 focus_before_help: FocusPane::List,
17863 show_help: false,
17864 help_scroll_offset: 0,
17865 show_quit_confirm: false,
17866 modal_overlay: None,
17867 modal_confirm_result: None,
17868 history_confidence_index: 0,
17869 history_view_mode: HistoryViewMode::Bead,
17870 history_event_cursor: 0,
17871 history_related_bead_cursor: 0,
17872 history_bead_commit_cursor: 0,
17873 history_git_cache: None,
17874 history_search_active: false,
17875 history_search_query: String::new(),
17876 history_search_match_cursor: 0,
17877 history_search_mode: HistorySearchMode::All,
17878 history_show_file_tree: false,
17879 history_file_tree_cursor: 0,
17880 history_file_tree_filter: None,
17881 history_file_tree_focus: false,
17882 history_status_msg: String::new(),
17883 board_search_active: false,
17884 board_search_query: String::new(),
17885 board_search_match_cursor: 0,
17886 board_detail_scroll_offset: 0,
17887 detail_scroll_offset: 0,
17888 main_search_active: false,
17889 main_search_query: String::new(),
17890 main_search_match_cursor: 0,
17891 list_scroll_offset: Cell::new(0),
17892 list_viewport_height: Cell::new(0),
17893 graph_search_active: false,
17894 graph_search_query: String::new(),
17895 graph_search_match_cursor: 0,
17896 insights_search_active: false,
17897 insights_search_query: String::new(),
17898 insights_search_match_cursor: 0,
17899 insights_panel: InsightsPanel::Bottlenecks,
17900 insights_heatmap: None,
17901 insights_show_explanations: true,
17902 insights_show_calc_proof: false,
17903 detail_dep_cursor: 0,
17904 actionable_plan: None,
17905 actionable_track_cursor: 0,
17906 actionable_item_cursor: 0,
17907 attention_result: None,
17908 attention_cursor: 0,
17909 tree_flat_nodes: Vec::new(),
17910 tree_cursor: 0,
17911 tree_collapsed: std::collections::HashSet::new(),
17912 tree_search_active: false,
17913 tree_search_query: String::new(),
17914 tree_search_match_cursor: 0,
17915 pending_g: false,
17916 g_pre_toggle_mode: None,
17917 pending_z: false,
17918 label_dashboard: None,
17919 label_dashboard_cursor: 0,
17920 flow_matrix: None,
17921 flow_matrix_row_cursor: 0,
17922 flow_matrix_col_cursor: 0,
17923 time_travel_ref_input: String::new(),
17924 time_travel_input_active: false,
17925 time_travel_diff: None,
17926 time_travel_category_cursor: 0,
17927 time_travel_issue_cursor: 0,
17928 time_travel_last_ref: None,
17929 sprint_data: Vec::new(),
17930 sprint_cursor: 0,
17931 sprint_issue_cursor: 0,
17932 modal_label_filter: None,
17933 modal_repo_filter: None,
17934 priority_hints_visible: false,
17935 status_msg: String::new(),
17936 slow_metrics_pending: false,
17937 #[cfg(test)]
17938 key_trace: Vec::new(),
17939 };
17940
17941 app.update(key(KeyCode::Char('s')));
17942 assert_eq!(app.board_grouping, BoardGrouping::Priority);
17943 assert!(app.list_panel_text().contains("Grouping: priority"));
17944 app.update(key(KeyCode::Char('3')));
17945 assert_eq!(selected_issue_id(&app), "BLK-1");
17946
17947 app.update(key(KeyCode::Char('s')));
17948 assert_eq!(app.board_grouping, BoardGrouping::Type);
17949 assert!(app.list_panel_text().contains("Grouping: type"));
17950 }
17951
17952 #[test]
17953 fn board_mode_advanced_navigation_and_empty_lane_toggle_work() {
17954 let mut app = BvrApp {
17955 analyzer: Analyzer::new(board_nav_issues()),
17956 repo_root: None,
17957 selected: 0,
17958 list_filter: ListFilter::All,
17959 list_sort: ListSort::Default,
17960 board_grouping: BoardGrouping::Status,
17961 board_empty_visibility: EmptyLaneVisibility::Auto,
17962 mode: ViewMode::Board,
17963 mode_before_history: ViewMode::Main,
17964 mode_back_stack: Vec::new(),
17965 focus: FocusPane::List,
17966 focus_before_help: FocusPane::List,
17967 show_help: false,
17968 help_scroll_offset: 0,
17969 show_quit_confirm: false,
17970 modal_overlay: None,
17971 modal_confirm_result: None,
17972 history_confidence_index: 0,
17973 history_view_mode: HistoryViewMode::Bead,
17974 history_event_cursor: 0,
17975 history_related_bead_cursor: 0,
17976 history_bead_commit_cursor: 0,
17977 history_git_cache: None,
17978 history_search_active: false,
17979 history_search_query: String::new(),
17980 history_search_match_cursor: 0,
17981 history_search_mode: HistorySearchMode::All,
17982 history_show_file_tree: false,
17983 history_file_tree_cursor: 0,
17984 history_file_tree_filter: None,
17985 history_file_tree_focus: false,
17986 history_status_msg: String::new(),
17987 board_search_active: false,
17988 board_search_query: String::new(),
17989 board_search_match_cursor: 0,
17990 board_detail_scroll_offset: 0,
17991 detail_scroll_offset: 0,
17992 main_search_active: false,
17993 main_search_query: String::new(),
17994 main_search_match_cursor: 0,
17995 list_scroll_offset: Cell::new(0),
17996 list_viewport_height: Cell::new(0),
17997 graph_search_active: false,
17998 graph_search_query: String::new(),
17999 graph_search_match_cursor: 0,
18000 insights_search_active: false,
18001 insights_search_query: String::new(),
18002 insights_search_match_cursor: 0,
18003 insights_panel: InsightsPanel::Bottlenecks,
18004 insights_heatmap: None,
18005 insights_show_explanations: true,
18006 insights_show_calc_proof: false,
18007 detail_dep_cursor: 0,
18008 actionable_plan: None,
18009 actionable_track_cursor: 0,
18010 actionable_item_cursor: 0,
18011 attention_result: None,
18012 attention_cursor: 0,
18013 tree_flat_nodes: Vec::new(),
18014 tree_cursor: 0,
18015 tree_collapsed: std::collections::HashSet::new(),
18016 tree_search_active: false,
18017 tree_search_query: String::new(),
18018 tree_search_match_cursor: 0,
18019 pending_g: false,
18020 g_pre_toggle_mode: None,
18021 pending_z: false,
18022 label_dashboard: None,
18023 label_dashboard_cursor: 0,
18024 flow_matrix: None,
18025 flow_matrix_row_cursor: 0,
18026 flow_matrix_col_cursor: 0,
18027 time_travel_ref_input: String::new(),
18028 time_travel_input_active: false,
18029 time_travel_diff: None,
18030 time_travel_category_cursor: 0,
18031 time_travel_issue_cursor: 0,
18032 time_travel_last_ref: None,
18033 sprint_data: Vec::new(),
18034 sprint_cursor: 0,
18035 sprint_issue_cursor: 0,
18036 modal_label_filter: None,
18037 modal_repo_filter: None,
18038 priority_hints_visible: false,
18039 status_msg: String::new(),
18040 slow_metrics_pending: false,
18041 #[cfg(test)]
18042 key_trace: Vec::new(),
18043 };
18044
18045 app.select_issue_by_id("OPEN-1");
18046 app.update(key(KeyCode::Char('$')));
18047 assert_eq!(selected_issue_id(&app), "OPEN-2");
18048 app.update(key(KeyCode::Char('0')));
18049 assert_eq!(selected_issue_id(&app), "OPEN-1");
18050
18051 app.update(key(KeyCode::Char('L')));
18052 assert_eq!(selected_issue_id(&app), "CLS-1");
18053 app.update(key(KeyCode::Char('H')));
18054 assert_eq!(selected_issue_id(&app), "OPEN-1");
18055
18056 app.update(key(KeyCode::Char('c')));
18057 let with_empty_lanes = app.list_panel_text();
18058 assert!(with_empty_lanes.contains("open"));
18059 assert!(with_empty_lanes.contains("in_progress"));
18060 assert!(with_empty_lanes.contains("blocked"));
18061
18062 app.update(key(KeyCode::Char('e')));
18064 assert_eq!(app.board_empty_visibility, EmptyLaneVisibility::ShowAll);
18065 app.update(key(KeyCode::Char('e')));
18066 assert_eq!(app.board_empty_visibility, EmptyLaneVisibility::HideEmpty);
18067 let without_empty_lanes = app.list_panel_text();
18068 assert!(!without_empty_lanes.contains("open"));
18069 assert!(!without_empty_lanes.contains("in_progress"));
18070 assert!(!without_empty_lanes.contains("blocked"));
18071 assert!(without_empty_lanes.contains("closed"));
18072 }
18073
18074 #[test]
18075 fn board_mode_home_and_end_stay_within_current_lane() {
18076 let mut app = BvrApp {
18077 analyzer: Analyzer::new(board_nav_issues()),
18078 repo_root: None,
18079 selected: 0,
18080 list_filter: ListFilter::All,
18081 list_sort: ListSort::Default,
18082 board_grouping: BoardGrouping::Status,
18083 board_empty_visibility: EmptyLaneVisibility::Auto,
18084 mode: ViewMode::Board,
18085 mode_before_history: ViewMode::Main,
18086 mode_back_stack: Vec::new(),
18087 focus: FocusPane::List,
18088 focus_before_help: FocusPane::List,
18089 show_help: false,
18090 help_scroll_offset: 0,
18091 show_quit_confirm: false,
18092 modal_overlay: None,
18093 modal_confirm_result: None,
18094 history_confidence_index: 0,
18095 history_view_mode: HistoryViewMode::Bead,
18096 history_event_cursor: 0,
18097 history_related_bead_cursor: 0,
18098 history_bead_commit_cursor: 0,
18099 history_git_cache: None,
18100 history_search_active: false,
18101 history_search_query: String::new(),
18102 history_search_match_cursor: 0,
18103 history_search_mode: HistorySearchMode::All,
18104 history_show_file_tree: false,
18105 history_file_tree_cursor: 0,
18106 history_file_tree_filter: None,
18107 history_file_tree_focus: false,
18108 history_status_msg: String::new(),
18109 board_search_active: false,
18110 board_search_query: String::new(),
18111 board_search_match_cursor: 0,
18112 board_detail_scroll_offset: 0,
18113 detail_scroll_offset: 0,
18114 main_search_active: false,
18115 main_search_query: String::new(),
18116 main_search_match_cursor: 0,
18117 list_scroll_offset: Cell::new(0),
18118 list_viewport_height: Cell::new(0),
18119 graph_search_active: false,
18120 graph_search_query: String::new(),
18121 graph_search_match_cursor: 0,
18122 insights_search_active: false,
18123 insights_search_query: String::new(),
18124 insights_search_match_cursor: 0,
18125 insights_panel: InsightsPanel::Bottlenecks,
18126 insights_heatmap: None,
18127 insights_show_explanations: true,
18128 insights_show_calc_proof: false,
18129 detail_dep_cursor: 0,
18130 actionable_plan: None,
18131 actionable_track_cursor: 0,
18132 actionable_item_cursor: 0,
18133 attention_result: None,
18134 attention_cursor: 0,
18135 tree_flat_nodes: Vec::new(),
18136 tree_cursor: 0,
18137 tree_collapsed: std::collections::HashSet::new(),
18138 tree_search_active: false,
18139 tree_search_query: String::new(),
18140 tree_search_match_cursor: 0,
18141 pending_g: false,
18142 g_pre_toggle_mode: None,
18143 pending_z: false,
18144 label_dashboard: None,
18145 label_dashboard_cursor: 0,
18146 flow_matrix: None,
18147 flow_matrix_row_cursor: 0,
18148 flow_matrix_col_cursor: 0,
18149 time_travel_ref_input: String::new(),
18150 time_travel_input_active: false,
18151 time_travel_diff: None,
18152 time_travel_category_cursor: 0,
18153 time_travel_issue_cursor: 0,
18154 time_travel_last_ref: None,
18155 sprint_data: Vec::new(),
18156 sprint_cursor: 0,
18157 sprint_issue_cursor: 0,
18158 modal_label_filter: None,
18159 modal_repo_filter: None,
18160 priority_hints_visible: false,
18161 status_msg: String::new(),
18162 slow_metrics_pending: false,
18163 #[cfg(test)]
18164 key_trace: Vec::new(),
18165 };
18166
18167 app.select_issue_by_id("OPEN-1");
18168 app.update(key(KeyCode::End));
18169 assert_eq!(selected_issue_id(&app), "OPEN-2");
18170
18171 app.update(key(KeyCode::Home));
18172 assert_eq!(selected_issue_id(&app), "OPEN-1");
18173
18174 app.update(key(KeyCode::Char('l')));
18175 assert_eq!(selected_issue_id(&app), "IP-1");
18176 app.update(key(KeyCode::End));
18177 assert_eq!(selected_issue_id(&app), "IP-1");
18178 }
18179
18180 #[test]
18181 fn board_mode_h_l_move_between_lanes_without_entering_history() {
18182 let mut app = BvrApp {
18183 analyzer: Analyzer::new(board_nav_issues()),
18184 repo_root: None,
18185 selected: 0,
18186 list_filter: ListFilter::All,
18187 list_sort: ListSort::Default,
18188 board_grouping: BoardGrouping::Status,
18189 board_empty_visibility: EmptyLaneVisibility::Auto,
18190 mode: ViewMode::Board,
18191 mode_before_history: ViewMode::Main,
18192 mode_back_stack: Vec::new(),
18193 focus: FocusPane::List,
18194 focus_before_help: FocusPane::List,
18195 show_help: false,
18196 help_scroll_offset: 0,
18197 show_quit_confirm: false,
18198 modal_overlay: None,
18199 modal_confirm_result: None,
18200 history_confidence_index: 0,
18201 history_view_mode: HistoryViewMode::Bead,
18202 history_event_cursor: 0,
18203 history_related_bead_cursor: 0,
18204 history_bead_commit_cursor: 0,
18205 history_git_cache: None,
18206 history_search_active: false,
18207 history_search_query: String::new(),
18208 history_search_match_cursor: 0,
18209 history_search_mode: HistorySearchMode::All,
18210 history_show_file_tree: false,
18211 history_file_tree_cursor: 0,
18212 history_file_tree_filter: None,
18213 history_file_tree_focus: false,
18214 history_status_msg: String::new(),
18215 board_search_active: false,
18216 board_search_query: String::new(),
18217 board_search_match_cursor: 0,
18218 board_detail_scroll_offset: 0,
18219 detail_scroll_offset: 0,
18220 main_search_active: false,
18221 main_search_query: String::new(),
18222 main_search_match_cursor: 0,
18223 list_scroll_offset: Cell::new(0),
18224 list_viewport_height: Cell::new(0),
18225 graph_search_active: false,
18226 graph_search_query: String::new(),
18227 graph_search_match_cursor: 0,
18228 insights_search_active: false,
18229 insights_search_query: String::new(),
18230 insights_search_match_cursor: 0,
18231 insights_panel: InsightsPanel::Bottlenecks,
18232 insights_heatmap: None,
18233 insights_show_explanations: true,
18234 insights_show_calc_proof: false,
18235 detail_dep_cursor: 0,
18236 actionable_plan: None,
18237 actionable_track_cursor: 0,
18238 actionable_item_cursor: 0,
18239 attention_result: None,
18240 attention_cursor: 0,
18241 tree_flat_nodes: Vec::new(),
18242 tree_cursor: 0,
18243 tree_collapsed: std::collections::HashSet::new(),
18244 tree_search_active: false,
18245 tree_search_query: String::new(),
18246 tree_search_match_cursor: 0,
18247 pending_g: false,
18248 g_pre_toggle_mode: None,
18249 pending_z: false,
18250 label_dashboard: None,
18251 label_dashboard_cursor: 0,
18252 flow_matrix: None,
18253 flow_matrix_row_cursor: 0,
18254 flow_matrix_col_cursor: 0,
18255 time_travel_ref_input: String::new(),
18256 time_travel_input_active: false,
18257 time_travel_diff: None,
18258 time_travel_category_cursor: 0,
18259 time_travel_issue_cursor: 0,
18260 time_travel_last_ref: None,
18261 sprint_data: Vec::new(),
18262 sprint_cursor: 0,
18263 sprint_issue_cursor: 0,
18264 modal_label_filter: None,
18265 modal_repo_filter: None,
18266 priority_hints_visible: false,
18267 status_msg: String::new(),
18268 slow_metrics_pending: false,
18269 #[cfg(test)]
18270 key_trace: Vec::new(),
18271 };
18272
18273 app.select_issue_by_id("OPEN-1");
18274 app.update(key(KeyCode::Char('l')));
18275 assert_eq!(selected_issue_id(&app), "IP-1");
18276 assert!(matches!(app.mode, ViewMode::Board));
18277
18278 app.update(key(KeyCode::Char('l')));
18279 assert_eq!(selected_issue_id(&app), "CLS-1");
18280 assert!(matches!(app.mode, ViewMode::Board));
18281
18282 app.update(key(KeyCode::Char('h')));
18283 assert_eq!(selected_issue_id(&app), "IP-1");
18284 assert!(matches!(app.mode, ViewMode::Board));
18285 }
18286
18287 #[test]
18288 fn board_mode_j_k_stay_within_current_lane() {
18289 let mut app = BvrApp {
18290 analyzer: Analyzer::new(board_nav_issues()),
18291 repo_root: None,
18292 selected: 0,
18293 list_filter: ListFilter::All,
18294 list_sort: ListSort::Default,
18295 board_grouping: BoardGrouping::Status,
18296 board_empty_visibility: EmptyLaneVisibility::Auto,
18297 mode: ViewMode::Board,
18298 mode_before_history: ViewMode::Main,
18299 mode_back_stack: Vec::new(),
18300 focus: FocusPane::List,
18301 focus_before_help: FocusPane::List,
18302 show_help: false,
18303 help_scroll_offset: 0,
18304 show_quit_confirm: false,
18305 modal_overlay: None,
18306 modal_confirm_result: None,
18307 history_confidence_index: 0,
18308 history_view_mode: HistoryViewMode::Bead,
18309 history_event_cursor: 0,
18310 history_related_bead_cursor: 0,
18311 history_bead_commit_cursor: 0,
18312 history_git_cache: None,
18313 history_search_active: false,
18314 history_search_query: String::new(),
18315 history_search_match_cursor: 0,
18316 history_search_mode: HistorySearchMode::All,
18317 history_show_file_tree: false,
18318 history_file_tree_cursor: 0,
18319 history_file_tree_filter: None,
18320 history_file_tree_focus: false,
18321 history_status_msg: String::new(),
18322 board_search_active: false,
18323 board_search_query: String::new(),
18324 board_search_match_cursor: 0,
18325 board_detail_scroll_offset: 0,
18326 detail_scroll_offset: 0,
18327 main_search_active: false,
18328 main_search_query: String::new(),
18329 main_search_match_cursor: 0,
18330 list_scroll_offset: Cell::new(0),
18331 list_viewport_height: Cell::new(0),
18332 graph_search_active: false,
18333 graph_search_query: String::new(),
18334 graph_search_match_cursor: 0,
18335 insights_search_active: false,
18336 insights_search_query: String::new(),
18337 insights_search_match_cursor: 0,
18338 insights_panel: InsightsPanel::Bottlenecks,
18339 insights_heatmap: None,
18340 insights_show_explanations: true,
18341 insights_show_calc_proof: false,
18342 detail_dep_cursor: 0,
18343 actionable_plan: None,
18344 actionable_track_cursor: 0,
18345 actionable_item_cursor: 0,
18346 attention_result: None,
18347 attention_cursor: 0,
18348 tree_flat_nodes: Vec::new(),
18349 tree_cursor: 0,
18350 tree_collapsed: std::collections::HashSet::new(),
18351 tree_search_active: false,
18352 tree_search_query: String::new(),
18353 tree_search_match_cursor: 0,
18354 pending_g: false,
18355 g_pre_toggle_mode: None,
18356 pending_z: false,
18357 label_dashboard: None,
18358 label_dashboard_cursor: 0,
18359 flow_matrix: None,
18360 flow_matrix_row_cursor: 0,
18361 flow_matrix_col_cursor: 0,
18362 time_travel_ref_input: String::new(),
18363 time_travel_input_active: false,
18364 time_travel_diff: None,
18365 time_travel_category_cursor: 0,
18366 time_travel_issue_cursor: 0,
18367 time_travel_last_ref: None,
18368 sprint_data: Vec::new(),
18369 sprint_cursor: 0,
18370 sprint_issue_cursor: 0,
18371 modal_label_filter: None,
18372 modal_repo_filter: None,
18373 priority_hints_visible: false,
18374 status_msg: String::new(),
18375 slow_metrics_pending: false,
18376 #[cfg(test)]
18377 key_trace: Vec::new(),
18378 };
18379
18380 app.select_issue_by_id("OPEN-1");
18381 app.update(key(KeyCode::Char('j')));
18382 assert_eq!(selected_issue_id(&app), "OPEN-2");
18383
18384 app.update(key(KeyCode::Char('j')));
18385 assert_eq!(selected_issue_id(&app), "OPEN-2");
18386
18387 app.update(key(KeyCode::Char('k')));
18388 assert_eq!(selected_issue_id(&app), "OPEN-1");
18389
18390 app.update(key(KeyCode::Char('k')));
18391 assert_eq!(selected_issue_id(&app), "OPEN-1");
18392 assert!(matches!(app.mode, ViewMode::Board));
18393 }
18394
18395 #[test]
18396 fn board_mode_ctrl_d_u_page_within_current_lane() {
18397 let mut app = BvrApp {
18398 analyzer: Analyzer::new(board_nav_issues()),
18399 repo_root: None,
18400 selected: 0,
18401 list_filter: ListFilter::All,
18402 list_sort: ListSort::Default,
18403 board_grouping: BoardGrouping::Status,
18404 board_empty_visibility: EmptyLaneVisibility::Auto,
18405 mode: ViewMode::Board,
18406 mode_before_history: ViewMode::Main,
18407 mode_back_stack: Vec::new(),
18408 focus: FocusPane::List,
18409 focus_before_help: FocusPane::List,
18410 show_help: false,
18411 help_scroll_offset: 0,
18412 show_quit_confirm: false,
18413 modal_overlay: None,
18414 modal_confirm_result: None,
18415 history_confidence_index: 0,
18416 history_view_mode: HistoryViewMode::Bead,
18417 history_event_cursor: 0,
18418 history_related_bead_cursor: 0,
18419 history_bead_commit_cursor: 0,
18420 history_git_cache: None,
18421 history_search_active: false,
18422 history_search_query: String::new(),
18423 history_search_match_cursor: 0,
18424 history_search_mode: HistorySearchMode::All,
18425 history_show_file_tree: false,
18426 history_file_tree_cursor: 0,
18427 history_file_tree_filter: None,
18428 history_file_tree_focus: false,
18429 history_status_msg: String::new(),
18430 board_search_active: false,
18431 board_search_query: String::new(),
18432 board_search_match_cursor: 0,
18433 board_detail_scroll_offset: 0,
18434 detail_scroll_offset: 0,
18435 main_search_active: false,
18436 main_search_query: String::new(),
18437 main_search_match_cursor: 0,
18438 list_scroll_offset: Cell::new(0),
18439 list_viewport_height: Cell::new(0),
18440 graph_search_active: false,
18441 graph_search_query: String::new(),
18442 graph_search_match_cursor: 0,
18443 insights_search_active: false,
18444 insights_search_query: String::new(),
18445 insights_search_match_cursor: 0,
18446 insights_panel: InsightsPanel::Bottlenecks,
18447 insights_heatmap: None,
18448 insights_show_explanations: true,
18449 insights_show_calc_proof: false,
18450 detail_dep_cursor: 0,
18451 actionable_plan: None,
18452 actionable_track_cursor: 0,
18453 actionable_item_cursor: 0,
18454 attention_result: None,
18455 attention_cursor: 0,
18456 tree_flat_nodes: Vec::new(),
18457 tree_cursor: 0,
18458 tree_collapsed: std::collections::HashSet::new(),
18459 tree_search_active: false,
18460 tree_search_query: String::new(),
18461 tree_search_match_cursor: 0,
18462 pending_g: false,
18463 g_pre_toggle_mode: None,
18464 pending_z: false,
18465 label_dashboard: None,
18466 label_dashboard_cursor: 0,
18467 flow_matrix: None,
18468 flow_matrix_row_cursor: 0,
18469 flow_matrix_col_cursor: 0,
18470 time_travel_ref_input: String::new(),
18471 time_travel_input_active: false,
18472 time_travel_diff: None,
18473 time_travel_category_cursor: 0,
18474 time_travel_issue_cursor: 0,
18475 time_travel_last_ref: None,
18476 sprint_data: Vec::new(),
18477 sprint_cursor: 0,
18478 sprint_issue_cursor: 0,
18479 modal_label_filter: None,
18480 modal_repo_filter: None,
18481 priority_hints_visible: false,
18482 status_msg: String::new(),
18483 slow_metrics_pending: false,
18484 #[cfg(test)]
18485 key_trace: Vec::new(),
18486 };
18487
18488 app.select_issue_by_id("OPEN-1");
18489 assert_eq!(selected_issue_id(&app), "OPEN-1");
18490 app.update(key_ctrl(KeyCode::Char('d')));
18491 assert_eq!(selected_issue_id(&app), "OPEN-2");
18492
18493 app.update(key_ctrl(KeyCode::Char('u')));
18494 assert_eq!(selected_issue_id(&app), "OPEN-1");
18495 assert!(matches!(app.mode, ViewMode::Board));
18496 }
18497
18498 #[test]
18499 fn board_mode_search_query_and_match_cycling_work() {
18500 let mut app = BvrApp {
18501 analyzer: Analyzer::new(board_nav_issues()),
18502 repo_root: None,
18503 selected: 0,
18504 list_filter: ListFilter::All,
18505 list_sort: ListSort::Default,
18506 board_grouping: BoardGrouping::Status,
18507 board_empty_visibility: EmptyLaneVisibility::Auto,
18508 mode: ViewMode::Board,
18509 mode_before_history: ViewMode::Main,
18510 mode_back_stack: Vec::new(),
18511 focus: FocusPane::List,
18512 focus_before_help: FocusPane::List,
18513 show_help: false,
18514 help_scroll_offset: 0,
18515 show_quit_confirm: false,
18516 modal_overlay: None,
18517 modal_confirm_result: None,
18518 history_confidence_index: 0,
18519 history_view_mode: HistoryViewMode::Bead,
18520 history_event_cursor: 0,
18521 history_related_bead_cursor: 0,
18522 history_bead_commit_cursor: 0,
18523 history_git_cache: None,
18524 history_search_active: false,
18525 history_search_query: String::new(),
18526 history_search_match_cursor: 0,
18527 history_search_mode: HistorySearchMode::All,
18528 history_show_file_tree: false,
18529 history_file_tree_cursor: 0,
18530 history_file_tree_filter: None,
18531 history_file_tree_focus: false,
18532 history_status_msg: String::new(),
18533 board_search_active: false,
18534 board_search_query: String::new(),
18535 board_search_match_cursor: 0,
18536 board_detail_scroll_offset: 0,
18537 detail_scroll_offset: 0,
18538 main_search_active: false,
18539 main_search_query: String::new(),
18540 main_search_match_cursor: 0,
18541 list_scroll_offset: Cell::new(0),
18542 list_viewport_height: Cell::new(0),
18543 graph_search_active: false,
18544 graph_search_query: String::new(),
18545 graph_search_match_cursor: 0,
18546 insights_search_active: false,
18547 insights_search_query: String::new(),
18548 insights_search_match_cursor: 0,
18549 insights_panel: InsightsPanel::Bottlenecks,
18550 insights_heatmap: None,
18551 insights_show_explanations: true,
18552 insights_show_calc_proof: false,
18553 detail_dep_cursor: 0,
18554 actionable_plan: None,
18555 actionable_track_cursor: 0,
18556 actionable_item_cursor: 0,
18557 attention_result: None,
18558 attention_cursor: 0,
18559 tree_flat_nodes: Vec::new(),
18560 tree_cursor: 0,
18561 tree_collapsed: std::collections::HashSet::new(),
18562 tree_search_active: false,
18563 tree_search_query: String::new(),
18564 tree_search_match_cursor: 0,
18565 pending_g: false,
18566 g_pre_toggle_mode: None,
18567 pending_z: false,
18568 label_dashboard: None,
18569 label_dashboard_cursor: 0,
18570 flow_matrix: None,
18571 flow_matrix_row_cursor: 0,
18572 flow_matrix_col_cursor: 0,
18573 time_travel_ref_input: String::new(),
18574 time_travel_input_active: false,
18575 time_travel_diff: None,
18576 time_travel_category_cursor: 0,
18577 time_travel_issue_cursor: 0,
18578 time_travel_last_ref: None,
18579 sprint_data: Vec::new(),
18580 sprint_cursor: 0,
18581 sprint_issue_cursor: 0,
18582 modal_label_filter: None,
18583 modal_repo_filter: None,
18584 priority_hints_visible: false,
18585 status_msg: String::new(),
18586 slow_metrics_pending: false,
18587 #[cfg(test)]
18588 key_trace: Vec::new(),
18589 };
18590
18591 app.update(key(KeyCode::Char('/')));
18592 assert!(app.board_search_active);
18593 assert!(app.board_search_query.is_empty());
18594
18595 for ch in ['o', 'p', 'e'] {
18596 app.update(key(KeyCode::Char(ch)));
18597 }
18598
18599 assert_eq!(app.board_search_query, "ope");
18600 assert_eq!(selected_issue_id(&app), "OPEN-1");
18601
18602 app.update(key(KeyCode::Char('n')));
18603 assert_eq!(selected_issue_id(&app), "OPEN-2");
18604
18605 app.update(key(KeyCode::Char('N')));
18606 assert_eq!(selected_issue_id(&app), "OPEN-1");
18607
18608 app.update(key(KeyCode::Enter));
18609 assert!(!app.board_search_active);
18610 assert_eq!(app.board_search_query, "ope");
18611
18612 app.update(key(KeyCode::Char('n')));
18613 assert_eq!(selected_issue_id(&app), "OPEN-2");
18614 }
18615
18616 #[test]
18617 fn board_mode_search_escape_clears_query_and_blocks_filter_hotkeys() {
18618 let mut app = BvrApp {
18619 analyzer: Analyzer::new(board_nav_issues()),
18620 repo_root: None,
18621 selected: 0,
18622 list_filter: ListFilter::All,
18623 list_sort: ListSort::Default,
18624 board_grouping: BoardGrouping::Status,
18625 board_empty_visibility: EmptyLaneVisibility::Auto,
18626 mode: ViewMode::Board,
18627 mode_before_history: ViewMode::Main,
18628 mode_back_stack: Vec::new(),
18629 focus: FocusPane::List,
18630 focus_before_help: FocusPane::List,
18631 show_help: false,
18632 help_scroll_offset: 0,
18633 show_quit_confirm: false,
18634 modal_overlay: None,
18635 modal_confirm_result: None,
18636 history_confidence_index: 0,
18637 history_view_mode: HistoryViewMode::Bead,
18638 history_event_cursor: 0,
18639 history_related_bead_cursor: 0,
18640 history_bead_commit_cursor: 0,
18641 history_git_cache: None,
18642 history_search_active: false,
18643 history_search_query: String::new(),
18644 history_search_match_cursor: 0,
18645 history_search_mode: HistorySearchMode::All,
18646 history_show_file_tree: false,
18647 history_file_tree_cursor: 0,
18648 history_file_tree_filter: None,
18649 history_file_tree_focus: false,
18650 history_status_msg: String::new(),
18651 board_search_active: false,
18652 board_search_query: String::new(),
18653 board_search_match_cursor: 0,
18654 board_detail_scroll_offset: 0,
18655 detail_scroll_offset: 0,
18656 main_search_active: false,
18657 main_search_query: String::new(),
18658 main_search_match_cursor: 0,
18659 list_scroll_offset: Cell::new(0),
18660 list_viewport_height: Cell::new(0),
18661 graph_search_active: false,
18662 graph_search_query: String::new(),
18663 graph_search_match_cursor: 0,
18664 insights_search_active: false,
18665 insights_search_query: String::new(),
18666 insights_search_match_cursor: 0,
18667 insights_panel: InsightsPanel::Bottlenecks,
18668 insights_heatmap: None,
18669 insights_show_explanations: true,
18670 insights_show_calc_proof: false,
18671 detail_dep_cursor: 0,
18672 actionable_plan: None,
18673 actionable_track_cursor: 0,
18674 actionable_item_cursor: 0,
18675 attention_result: None,
18676 attention_cursor: 0,
18677 tree_flat_nodes: Vec::new(),
18678 tree_cursor: 0,
18679 tree_collapsed: std::collections::HashSet::new(),
18680 tree_search_active: false,
18681 tree_search_query: String::new(),
18682 tree_search_match_cursor: 0,
18683 pending_g: false,
18684 g_pre_toggle_mode: None,
18685 pending_z: false,
18686 label_dashboard: None,
18687 label_dashboard_cursor: 0,
18688 flow_matrix: None,
18689 flow_matrix_row_cursor: 0,
18690 flow_matrix_col_cursor: 0,
18691 time_travel_ref_input: String::new(),
18692 time_travel_input_active: false,
18693 time_travel_diff: None,
18694 time_travel_category_cursor: 0,
18695 time_travel_issue_cursor: 0,
18696 time_travel_last_ref: None,
18697 sprint_data: Vec::new(),
18698 sprint_cursor: 0,
18699 sprint_issue_cursor: 0,
18700 modal_label_filter: None,
18701 modal_repo_filter: None,
18702 priority_hints_visible: false,
18703 status_msg: String::new(),
18704 slow_metrics_pending: false,
18705 #[cfg(test)]
18706 key_trace: Vec::new(),
18707 };
18708
18709 app.update(key(KeyCode::Char('/')));
18710 app.update(key(KeyCode::Char('c')));
18711 assert!(app.board_search_active);
18712 assert_eq!(app.board_search_query, "c");
18713 assert_eq!(app.list_filter, ListFilter::All);
18714
18715 app.update(key(KeyCode::Escape));
18716 assert!(!app.board_search_active);
18717 assert!(app.board_search_query.is_empty());
18718 }
18719
18720 #[test]
18721 fn board_mode_search_prefers_rendered_lane_order_over_storage_order() {
18722 let issues = vec![
18723 Issue {
18724 id: "BLK-1".to_string(),
18725 title: "Alpha blocked".to_string(),
18726 status: "blocked".to_string(),
18727 issue_type: "task".to_string(),
18728 ..Issue::default()
18729 },
18730 Issue {
18731 id: "OPEN-1".to_string(),
18732 title: "Alpha open".to_string(),
18733 status: "open".to_string(),
18734 issue_type: "task".to_string(),
18735 ..Issue::default()
18736 },
18737 ];
18738 let mut app = new_app_with_issues(ViewMode::Board, 0, issues);
18739
18740 let rendered_ids = app
18741 .board_visible_issue_indices_in_display_order()
18742 .into_iter()
18743 .map(|index| app.analyzer.issues[index].id.clone())
18744 .collect::<Vec<_>>();
18745 assert_eq!(rendered_ids, vec!["OPEN-1", "BLK-1"]);
18746
18747 app.update(key(KeyCode::Char('/')));
18748 app.update(key(KeyCode::Char('a')));
18749
18750 assert_eq!(selected_issue_id(&app), "OPEN-1");
18751 assert!(
18752 app.list_panel_text()
18753 .lines()
18754 .any(|line| line.contains('▶') && line.contains("OPEN-1"))
18755 );
18756 }
18757
18758 #[test]
18759 fn board_mode_detail_focus_shortcuts_drive_navigation_and_search() {
18760 let mut app = BvrApp {
18761 analyzer: Analyzer::new(board_nav_issues()),
18762 repo_root: None,
18763 selected: 0,
18764 list_filter: ListFilter::All,
18765 list_sort: ListSort::Default,
18766 board_grouping: BoardGrouping::Status,
18767 board_empty_visibility: EmptyLaneVisibility::Auto,
18768 mode: ViewMode::Board,
18769 mode_before_history: ViewMode::Main,
18770 mode_back_stack: Vec::new(),
18771 focus: FocusPane::List,
18772 focus_before_help: FocusPane::List,
18773 show_help: false,
18774 help_scroll_offset: 0,
18775 show_quit_confirm: false,
18776 modal_overlay: None,
18777 modal_confirm_result: None,
18778 history_confidence_index: 0,
18779 history_view_mode: HistoryViewMode::Bead,
18780 history_event_cursor: 0,
18781 history_related_bead_cursor: 0,
18782 history_bead_commit_cursor: 0,
18783 history_git_cache: None,
18784 history_search_active: false,
18785 history_search_query: String::new(),
18786 history_search_match_cursor: 0,
18787 history_search_mode: HistorySearchMode::All,
18788 history_show_file_tree: false,
18789 history_file_tree_cursor: 0,
18790 history_file_tree_filter: None,
18791 history_file_tree_focus: false,
18792 history_status_msg: String::new(),
18793 board_search_active: false,
18794 board_search_query: String::new(),
18795 board_search_match_cursor: 0,
18796 board_detail_scroll_offset: 0,
18797 detail_scroll_offset: 0,
18798 main_search_active: false,
18799 main_search_query: String::new(),
18800 main_search_match_cursor: 0,
18801 list_scroll_offset: Cell::new(0),
18802 list_viewport_height: Cell::new(0),
18803 graph_search_active: false,
18804 graph_search_query: String::new(),
18805 graph_search_match_cursor: 0,
18806 insights_search_active: false,
18807 insights_search_query: String::new(),
18808 insights_search_match_cursor: 0,
18809 insights_panel: InsightsPanel::Bottlenecks,
18810 insights_heatmap: None,
18811 insights_show_explanations: true,
18812 insights_show_calc_proof: false,
18813 detail_dep_cursor: 0,
18814 actionable_plan: None,
18815 actionable_track_cursor: 0,
18816 actionable_item_cursor: 0,
18817 attention_result: None,
18818 attention_cursor: 0,
18819 tree_flat_nodes: Vec::new(),
18820 tree_cursor: 0,
18821 tree_collapsed: std::collections::HashSet::new(),
18822 tree_search_active: false,
18823 tree_search_query: String::new(),
18824 tree_search_match_cursor: 0,
18825 pending_g: false,
18826 g_pre_toggle_mode: None,
18827 pending_z: false,
18828 label_dashboard: None,
18829 label_dashboard_cursor: 0,
18830 flow_matrix: None,
18831 flow_matrix_row_cursor: 0,
18832 flow_matrix_col_cursor: 0,
18833 time_travel_ref_input: String::new(),
18834 time_travel_input_active: false,
18835 time_travel_diff: None,
18836 time_travel_category_cursor: 0,
18837 time_travel_issue_cursor: 0,
18838 time_travel_last_ref: None,
18839 sprint_data: Vec::new(),
18840 sprint_cursor: 0,
18841 sprint_issue_cursor: 0,
18842 modal_label_filter: None,
18843 modal_repo_filter: None,
18844 priority_hints_visible: false,
18845 status_msg: String::new(),
18846 slow_metrics_pending: false,
18847 #[cfg(test)]
18848 key_trace: Vec::new(),
18849 };
18850
18851 app.select_issue_by_id("OPEN-1");
18852 app.update(key(KeyCode::Tab));
18853 assert_eq!(app.focus, FocusPane::Detail);
18854
18855 app.update(key(KeyCode::Char('j')));
18856 assert_eq!(selected_issue_id(&app), "OPEN-2");
18857 assert_eq!(app.focus, FocusPane::Detail);
18858
18859 app.update(key(KeyCode::Char('l')));
18860 assert_eq!(selected_issue_id(&app), "IP-1");
18861
18862 app.update(key(KeyCode::Char('L')));
18863 assert_eq!(selected_issue_id(&app), "CLS-1");
18864
18865 app.update(key(KeyCode::Char('H')));
18866 assert_eq!(selected_issue_id(&app), "OPEN-1");
18867
18868 app.update(key(KeyCode::Char('/')));
18869 assert!(app.board_search_active);
18870 app.update(key(KeyCode::Char('o')));
18871 app.update(key(KeyCode::Char('p')));
18872 app.update(key(KeyCode::Char('e')));
18873 assert_eq!(app.board_search_query, "ope");
18874 assert_eq!(selected_issue_id(&app), "OPEN-1");
18875
18876 app.update(key(KeyCode::Enter));
18877 assert!(!app.board_search_active);
18878
18879 app.update(key(KeyCode::Char('n')));
18880 assert_eq!(selected_issue_id(&app), "OPEN-2");
18881 assert_eq!(app.focus, FocusPane::Detail);
18882 }
18883
18884 #[test]
18885 fn board_detail_scroll_shortcuts_reset_when_selection_changes() {
18886 let mut app = new_app(ViewMode::Board, 0);
18887 app.focus = FocusPane::Detail;
18888
18889 app.update(key_ctrl(KeyCode::Char('j')));
18890 assert_eq!(app.board_detail_scroll_offset, 3);
18891
18892 app.update(key_ctrl(KeyCode::Char('d')));
18893 assert_eq!(app.board_detail_scroll_offset, 13);
18894
18895 app.focus = FocusPane::List;
18896 app.update(key(KeyCode::Char('j')));
18897 assert_eq!(selected_issue_id(&app), "B");
18898 assert_eq!(app.board_detail_scroll_offset, 0);
18899 }
18900
18901 #[test]
18902 fn board_detail_render_state_applies_scroll_offset() {
18903 let mut app = new_app(ViewMode::Board, 1);
18904 app.board_detail_scroll_offset = 4;
18905
18906 let full = app.board_detail_text();
18907 let total_lines = full.lines().count();
18908 let visible_height = 6;
18909 let expected_offset = 4.min(total_lines.saturating_sub(visible_height));
18910 let expected = if expected_offset == 0 {
18911 full
18912 } else {
18913 full.lines()
18914 .skip(expected_offset)
18915 .collect::<Vec<_>>()
18916 .join("\n")
18917 };
18918
18919 let (visible, offset, reported_total) = app.board_detail_render_state(visible_height);
18920 assert_eq!(reported_total, total_lines);
18921 assert_eq!(offset, expected_offset);
18922 assert_eq!(visible, expected);
18923 }
18924
18925 #[test]
18926 fn main_detail_scroll_shortcuts_move_offset() {
18927 let mut app = new_app(ViewMode::Main, 0);
18928 app.focus = FocusPane::Detail;
18929
18930 app.update(key_ctrl(KeyCode::Char('j')));
18931 assert_eq!(app.detail_scroll_offset, 3);
18932
18933 app.update(key_ctrl(KeyCode::Char('d')));
18934 assert_eq!(app.detail_scroll_offset, 13);
18935
18936 app.update(key_ctrl(KeyCode::Char('k')));
18937 assert_eq!(app.detail_scroll_offset, 10);
18938
18939 app.update(key_ctrl(KeyCode::Char('u')));
18940 assert_eq!(app.detail_scroll_offset, 0);
18941 }
18942
18943 #[test]
18944 fn main_detail_scroll_resets_when_selection_changes() {
18945 let mut app = new_app(ViewMode::Main, 0);
18946 app.focus = FocusPane::Detail;
18947
18948 app.update(key_ctrl(KeyCode::Char('j')));
18949 assert_eq!(app.detail_scroll_offset, 3);
18950
18951 app.focus = FocusPane::List;
18952 app.update(key(KeyCode::Char('j')));
18953 assert_eq!(selected_issue_id(&app), "B");
18954 assert_eq!(app.detail_scroll_offset, 0);
18955 }
18956
18957 #[test]
18958 fn main_detail_scroll_ignored_without_detail_focus() {
18959 let mut app = new_app(ViewMode::Main, 0);
18960 assert_eq!(app.focus, FocusPane::List);
18961
18962 app.update(key_ctrl(KeyCode::Char('j')));
18963 assert_eq!(app.detail_scroll_offset, 0);
18964 }
18965
18966 #[test]
18967 fn detail_scroll_works_in_all_modes() {
18968 for mode in [
18970 ViewMode::Main,
18971 ViewMode::Graph,
18972 ViewMode::Insights,
18973 ViewMode::History,
18974 ] {
18975 let mut app = new_app(mode, 0);
18976 app.focus = FocusPane::Detail;
18977 app.scroll_detail(5);
18978 assert_eq!(
18979 app.detail_scroll_offset, 5,
18980 "scroll_detail should work in {mode:?}"
18981 );
18982 }
18983 }
18984
18985 #[test]
18986 fn main_footer_shows_scroll_hint_when_detail_focused() {
18987 let mut app = new_app(ViewMode::Main, 0);
18988 app.focus = FocusPane::Detail;
18989
18990 let rendered = render_app(&app, 120, 40);
18991 assert!(
18992 rendered.contains("^j/k"),
18993 "expected scroll hint in footer, got:\n{rendered}"
18994 );
18995 }
18996
18997 #[test]
18998 fn board_mode_g_switches_to_graph_view() {
18999 let mut app = new_app(ViewMode::Board, 0);
19000 app.update(key(KeyCode::Char('g')));
19001 assert!(matches!(app.mode, ViewMode::Graph));
19002 assert_eq!(app.focus, FocusPane::List);
19003 }
19004
19005 #[test]
19006 fn board_status_grouping_places_unknown_status_in_other_lane() {
19007 let mut app = BvrApp {
19008 analyzer: Analyzer::new(board_with_unknown_status_issues()),
19009 repo_root: None,
19010 selected: 0,
19011 list_filter: ListFilter::All,
19012 list_sort: ListSort::Default,
19013 board_grouping: BoardGrouping::Status,
19014 board_empty_visibility: EmptyLaneVisibility::Auto,
19015 mode: ViewMode::Board,
19016 mode_before_history: ViewMode::Main,
19017 mode_back_stack: Vec::new(),
19018 focus: FocusPane::List,
19019 focus_before_help: FocusPane::List,
19020 show_help: false,
19021 help_scroll_offset: 0,
19022 show_quit_confirm: false,
19023 modal_overlay: None,
19024 modal_confirm_result: None,
19025 history_confidence_index: 0,
19026 history_view_mode: HistoryViewMode::Bead,
19027 history_event_cursor: 0,
19028 history_related_bead_cursor: 0,
19029 history_bead_commit_cursor: 0,
19030 history_git_cache: None,
19031 history_search_active: false,
19032 history_search_query: String::new(),
19033 history_search_match_cursor: 0,
19034 history_search_mode: HistorySearchMode::All,
19035 history_show_file_tree: false,
19036 history_file_tree_cursor: 0,
19037 history_file_tree_filter: None,
19038 history_file_tree_focus: false,
19039 history_status_msg: String::new(),
19040 board_search_active: false,
19041 board_search_query: String::new(),
19042 board_search_match_cursor: 0,
19043 board_detail_scroll_offset: 0,
19044 detail_scroll_offset: 0,
19045 main_search_active: false,
19046 main_search_query: String::new(),
19047 main_search_match_cursor: 0,
19048 list_scroll_offset: Cell::new(0),
19049 list_viewport_height: Cell::new(0),
19050 graph_search_active: false,
19051 graph_search_query: String::new(),
19052 graph_search_match_cursor: 0,
19053 insights_search_active: false,
19054 insights_search_query: String::new(),
19055 insights_search_match_cursor: 0,
19056 insights_panel: InsightsPanel::Bottlenecks,
19057 insights_heatmap: None,
19058 insights_show_explanations: true,
19059 insights_show_calc_proof: false,
19060 detail_dep_cursor: 0,
19061 actionable_plan: None,
19062 actionable_track_cursor: 0,
19063 actionable_item_cursor: 0,
19064 attention_result: None,
19065 attention_cursor: 0,
19066 tree_flat_nodes: Vec::new(),
19067 tree_cursor: 0,
19068 tree_collapsed: std::collections::HashSet::new(),
19069 tree_search_active: false,
19070 tree_search_query: String::new(),
19071 tree_search_match_cursor: 0,
19072 pending_g: false,
19073 g_pre_toggle_mode: None,
19074 pending_z: false,
19075 label_dashboard: None,
19076 label_dashboard_cursor: 0,
19077 flow_matrix: None,
19078 flow_matrix_row_cursor: 0,
19079 flow_matrix_col_cursor: 0,
19080 time_travel_ref_input: String::new(),
19081 time_travel_input_active: false,
19082 time_travel_diff: None,
19083 time_travel_category_cursor: 0,
19084 time_travel_issue_cursor: 0,
19085 time_travel_last_ref: None,
19086 sprint_data: Vec::new(),
19087 sprint_cursor: 0,
19088 sprint_issue_cursor: 0,
19089 modal_label_filter: None,
19090 modal_repo_filter: None,
19091 priority_hints_visible: false,
19092 status_msg: String::new(),
19093 slow_metrics_pending: false,
19094 #[cfg(test)]
19095 key_trace: Vec::new(),
19096 };
19097
19098 let list = app.list_panel_text();
19099 assert!(list.contains("other"));
19100 assert!(list.contains("QUE-1"));
19101
19102 app.update(key(KeyCode::Char('e')));
19104 assert_eq!(app.board_empty_visibility, EmptyLaneVisibility::ShowAll);
19105 app.update(key(KeyCode::Char('e')));
19106 assert_eq!(app.board_empty_visibility, EmptyLaneVisibility::HideEmpty);
19107 let hidden_empty = app.list_panel_text();
19108 assert!(hidden_empty.contains("open"));
19109 assert!(!hidden_empty.contains("in_progress"));
19110 assert!(!hidden_empty.contains("blocked"));
19111 assert!(!hidden_empty.contains("closed"));
19112 assert!(hidden_empty.contains("other"));
19113 }
19114
19115 #[test]
19116 fn sort_key_cycles_main_order_modes() {
19117 let mut app = BvrApp {
19118 analyzer: Analyzer::new(sortable_issues()),
19119 repo_root: None,
19120 selected: 0,
19121 list_filter: ListFilter::All,
19122 list_sort: ListSort::Default,
19123 board_grouping: BoardGrouping::Status,
19124 board_empty_visibility: EmptyLaneVisibility::Auto,
19125 mode: ViewMode::Main,
19126 mode_before_history: ViewMode::Main,
19127 mode_back_stack: Vec::new(),
19128 focus: FocusPane::List,
19129 focus_before_help: FocusPane::List,
19130 show_help: false,
19131 help_scroll_offset: 0,
19132 show_quit_confirm: false,
19133 modal_overlay: None,
19134 modal_confirm_result: None,
19135 history_confidence_index: 0,
19136 history_view_mode: HistoryViewMode::Bead,
19137 history_event_cursor: 0,
19138 history_related_bead_cursor: 0,
19139 history_bead_commit_cursor: 0,
19140 history_git_cache: None,
19141 history_search_active: false,
19142 history_search_query: String::new(),
19143 history_search_match_cursor: 0,
19144 history_search_mode: HistorySearchMode::All,
19145 history_show_file_tree: false,
19146 history_file_tree_cursor: 0,
19147 history_file_tree_filter: None,
19148 history_file_tree_focus: false,
19149 history_status_msg: String::new(),
19150 board_search_active: false,
19151 board_search_query: String::new(),
19152 board_search_match_cursor: 0,
19153 board_detail_scroll_offset: 0,
19154 detail_scroll_offset: 0,
19155 main_search_active: false,
19156 main_search_query: String::new(),
19157 main_search_match_cursor: 0,
19158 list_scroll_offset: Cell::new(0),
19159 list_viewport_height: Cell::new(0),
19160 graph_search_active: false,
19161 graph_search_query: String::new(),
19162 graph_search_match_cursor: 0,
19163 insights_search_active: false,
19164 insights_search_query: String::new(),
19165 insights_search_match_cursor: 0,
19166 insights_panel: InsightsPanel::Bottlenecks,
19167 insights_heatmap: None,
19168 insights_show_explanations: true,
19169 insights_show_calc_proof: false,
19170 detail_dep_cursor: 0,
19171 actionable_plan: None,
19172 actionable_track_cursor: 0,
19173 actionable_item_cursor: 0,
19174 attention_result: None,
19175 attention_cursor: 0,
19176 tree_flat_nodes: Vec::new(),
19177 tree_cursor: 0,
19178 tree_collapsed: std::collections::HashSet::new(),
19179 tree_search_active: false,
19180 tree_search_query: String::new(),
19181 tree_search_match_cursor: 0,
19182 pending_g: false,
19183 g_pre_toggle_mode: None,
19184 pending_z: false,
19185 label_dashboard: None,
19186 label_dashboard_cursor: 0,
19187 flow_matrix: None,
19188 flow_matrix_row_cursor: 0,
19189 flow_matrix_col_cursor: 0,
19190 time_travel_ref_input: String::new(),
19191 time_travel_input_active: false,
19192 time_travel_diff: None,
19193 time_travel_category_cursor: 0,
19194 time_travel_issue_cursor: 0,
19195 time_travel_last_ref: None,
19196 sprint_data: Vec::new(),
19197 sprint_cursor: 0,
19198 sprint_issue_cursor: 0,
19199 modal_label_filter: None,
19200 modal_repo_filter: None,
19201 priority_hints_visible: false,
19202 status_msg: String::new(),
19203 slow_metrics_pending: false,
19204 #[cfg(test)]
19205 key_trace: Vec::new(),
19206 };
19207
19208 assert_eq!(first_rendered_issue_id(&app), "M");
19210 assert_eq!(app.list_sort, ListSort::Default);
19211
19212 app.update(key(KeyCode::Char('s')));
19213 assert_eq!(app.list_sort, ListSort::CreatedAsc);
19214 assert_eq!(first_rendered_issue_id(&app), "Z");
19215
19216 app.update(key(KeyCode::Char('s')));
19217 assert_eq!(app.list_sort, ListSort::CreatedDesc);
19218 assert_eq!(first_rendered_issue_id(&app), "M");
19219
19220 app.update(key(KeyCode::Char('s')));
19221 assert_eq!(app.list_sort, ListSort::Priority);
19222 assert_eq!(first_rendered_issue_id(&app), "M");
19223
19224 app.update(key(KeyCode::Char('s')));
19225 assert_eq!(app.list_sort, ListSort::Updated);
19226 assert_eq!(first_rendered_issue_id(&app), "Z");
19227
19228 app.update(key(KeyCode::Char('s')));
19229 assert_eq!(app.list_sort, ListSort::PageRank);
19230
19231 app.update(key(KeyCode::Char('s')));
19232 assert_eq!(app.list_sort, ListSort::Blockers);
19233
19234 app.update(key(KeyCode::Char('s')));
19235 assert_eq!(app.list_sort, ListSort::Default);
19236 assert_eq!(first_rendered_issue_id(&app), "M");
19237 }
19238
19239 #[test]
19240 fn default_sort_treats_tombstone_as_closed_like() {
19241 let mut issues = sortable_issues();
19242 issues.push(Issue {
19243 id: "T".to_string(),
19244 title: "Tombstone".to_string(),
19245 status: "tombstone".to_string(),
19246 issue_type: "task".to_string(),
19247 priority: 0,
19248 ..Issue::default()
19249 });
19250
19251 let mut app = new_app(ViewMode::Main, 0);
19252 app.analyzer = Analyzer::new(issues);
19253 app.list_filter = ListFilter::All;
19254 app.list_sort = ListSort::Default;
19255
19256 let visible = app.visible_issue_indices();
19257 assert!(!visible.is_empty());
19258
19259 let first = &app.analyzer.issues[visible[0]].id;
19260 let last = &app.analyzer.issues[*visible.last().unwrap_or(&0)].id;
19261 assert_ne!(first, "T", "tombstone issue should not be sorted as open");
19262 assert_eq!(
19263 last, "T",
19264 "tombstone issue should sort with closed-like items"
19265 );
19266 }
19267
19268 #[test]
19269 fn board_detail_j_k_navigate_deps_when_detail_focused() {
19270 let mut app = new_app(ViewMode::Board, 0);
19272 app.mode = ViewMode::Board;
19273 assert_eq!(selected_issue_id(&app), "A");
19274
19275 app.update(key(KeyCode::Tab));
19277 assert_eq!(app.focus, FocusPane::Detail);
19278 assert_eq!(app.detail_dep_cursor, 0);
19279
19280 let deps = app.detail_dep_list();
19282 assert_eq!(deps, vec!["B".to_string()]);
19283
19284 app.update(key(KeyCode::Char('J')));
19286 assert_eq!(app.detail_dep_cursor, 0);
19287
19288 let detail = app.detail_panel_text();
19290 assert!(detail.contains('>'), "detail should show cursor marker");
19291 }
19292
19293 #[test]
19294 fn graph_detail_j_k_navigate_deps_when_detail_focused() {
19295 let mut app = new_app(ViewMode::Graph, 1);
19297 app.mode = ViewMode::Graph;
19298 assert_eq!(selected_issue_id(&app), "B");
19299
19300 app.update(key(KeyCode::Tab));
19301 assert_eq!(app.focus, FocusPane::Detail);
19302
19303 let deps = app.detail_dep_list();
19304 assert_eq!(deps, vec!["A".to_string()]);
19305
19306 app.update(key(KeyCode::Char('j')));
19308 assert_eq!(app.detail_dep_cursor, 0);
19309
19310 let detail = app.detail_panel_text();
19312 assert!(
19313 detail.contains('>'),
19314 "graph detail should show cursor marker"
19315 );
19316 }
19317
19318 #[test]
19319 fn graph_detail_text_surfaces_focus_context_for_node_and_edge() {
19320 let mut app = new_app(ViewMode::Graph, 1);
19321 app.mode = ViewMode::Graph;
19322
19323 let list_focus = app.detail_panel_text();
19324 assert!(list_focus.contains("Focus: node"));
19325 assert!(list_focus.contains("Focused edge: list focus"));
19326
19327 app.update(key(KeyCode::Tab));
19328 let detail_focus = app.detail_panel_text();
19329 assert!(detail_focus.contains("Focused edge: depends on"));
19330 assert!(detail_focus.contains("B -> A"));
19331 }
19332
19333 #[test]
19334 fn insights_detail_shows_deps_with_cursor_and_j_k_works() {
19335 let mut app = new_app(ViewMode::Insights, 1);
19337 app.mode = ViewMode::Insights;
19338 assert_eq!(selected_issue_id(&app), "B");
19339
19340 app.update(key(KeyCode::Tab));
19341 assert_eq!(app.focus, FocusPane::Detail);
19342
19343 let detail = app.detail_panel_text();
19344 assert!(
19345 detail.contains("Depends on"),
19346 "insights detail should show dependency section"
19347 );
19348 assert!(detail.contains('>'), "insights detail should show cursor");
19349 }
19350
19351 #[test]
19352 fn detail_dep_cursor_resets_on_selection_change() {
19353 let mut app = new_app(ViewMode::Graph, 0);
19354 app.mode = ViewMode::Graph;
19355 app.detail_dep_cursor = 5;
19356
19357 app.update(key(KeyCode::Char('j')));
19359 assert_eq!(app.detail_dep_cursor, 0);
19360 }
19361
19362 #[test]
19365 fn breakpoint_narrow_below_80() {
19366 assert_eq!(Breakpoint::from_width(40), Breakpoint::Narrow);
19367 assert_eq!(Breakpoint::from_width(79), Breakpoint::Narrow);
19368 }
19369
19370 #[test]
19371 fn breakpoint_medium_80_to_119() {
19372 assert_eq!(Breakpoint::from_width(80), Breakpoint::Medium);
19373 assert_eq!(Breakpoint::from_width(100), Breakpoint::Medium);
19374 assert_eq!(Breakpoint::from_width(119), Breakpoint::Medium);
19375 }
19376
19377 #[test]
19378 fn breakpoint_wide_120_plus() {
19379 assert_eq!(Breakpoint::from_width(120), Breakpoint::Wide);
19380 assert_eq!(Breakpoint::from_width(200), Breakpoint::Wide);
19381 }
19382
19383 #[test]
19384 fn breakpoint_list_detail_pct_sums_to_100() {
19385 for bp in [Breakpoint::Narrow, Breakpoint::Medium, Breakpoint::Wide] {
19386 let sum = bp.list_pct() + bp.detail_pct();
19387 assert!(
19388 (sum - 100.0).abs() < f32::EPSILON,
19389 "{bp:?} pcts sum to {sum}"
19390 );
19391 }
19392 }
19393
19394 #[test]
19395 fn breakpoint_narrow_gives_smaller_list() {
19396 assert!(Breakpoint::Narrow.list_pct() < Breakpoint::Medium.list_pct());
19397 }
19398
19399 #[test]
19400 fn breakpoint_wide_gives_larger_detail() {
19401 assert!(Breakpoint::Wide.detail_pct() > Breakpoint::Medium.detail_pct());
19402 }
19403
19404 #[test]
19405 fn history_layout_breakpoints_match_legacy() {
19406 assert_eq!(HistoryLayout::from_width(99), HistoryLayout::Narrow);
19407 assert_eq!(HistoryLayout::from_width(100), HistoryLayout::Standard);
19408 assert_eq!(HistoryLayout::from_width(149), HistoryLayout::Standard);
19409 assert_eq!(HistoryLayout::from_width(150), HistoryLayout::Wide);
19410 }
19411
19412 #[test]
19413 fn pane_split_two_pane_adjustment_persists_and_clamps() {
19414 let mut app = new_app(ViewMode::Main, 0);
19415 let _ = render_app(&app, 100, 24);
19416 assert_eq!(
19417 super::pane_split_state().two_pane_list_pct(Breakpoint::Medium),
19418 42.0
19419 );
19420
19421 app.update(Msg::KeyPress(KeyCode::Right, Modifiers::CTRL));
19422 assert_eq!(
19423 super::pane_split_state().two_pane_list_pct(Breakpoint::Medium),
19424 46.0
19425 );
19426
19427 app.mode = ViewMode::Board;
19428 let _ = render_app(&app, 100, 24);
19429 assert_eq!(
19430 super::pane_split_state().two_pane_list_pct(Breakpoint::Medium),
19431 46.0
19432 );
19433
19434 for _ in 0..20 {
19435 app.update(Msg::KeyPress(KeyCode::Left, Modifiers::CTRL));
19436 }
19437 assert_eq!(
19438 super::pane_split_state().two_pane_list_pct(Breakpoint::Medium),
19439 25.0
19440 );
19441 }
19442
19443 #[test]
19444 fn pane_split_history_wide_bead_adjustment_preserves_minimums() {
19445 let mut app = history_app_with_git_cache(HistoryViewMode::Bead, 0);
19446 app.focus = FocusPane::Middle;
19447 let _ = render_app(&app, 160, 24);
19448
19449 app.update(Msg::KeyPress(KeyCode::Right, Modifiers::CTRL));
19450 let split = super::pane_split_state();
19451 assert_eq!(split.history_wide_bead[1], 22.0);
19452 assert_eq!(split.history_wide_bead[2], 29.0);
19453 assert_eq!(split.history_wide_bead[3], 29.0);
19454
19455 for _ in 0..20 {
19456 app.update(Msg::KeyPress(KeyCode::Right, Modifiers::CTRL));
19457 }
19458 let clamped = super::pane_split_state().history_wide_bead;
19459 assert!(clamped[2] >= 15.0);
19460 assert!(clamped[3] >= 15.0);
19461 assert!((clamped.iter().sum::<f32>() - 100.0).abs() < f32::EPSILON);
19462 }
19463
19464 #[test]
19465 fn pane_split_reset_shortcut_restores_defaults() {
19466 let mut app = new_app(ViewMode::Main, 0);
19467 app.reset_pane_split_state();
19468 let _ = render_app(&app, 100, 24);
19469 app.update(Msg::KeyPress(KeyCode::Right, Modifiers::CTRL));
19470 assert_eq!(
19471 super::pane_split_state().two_pane_list_pct(Breakpoint::Medium),
19472 46.0
19473 );
19474
19475 app.update(Msg::KeyPress(KeyCode::Char('0'), Modifiers::CTRL));
19476 assert_eq!(
19477 super::pane_split_state().two_pane_list_pct(Breakpoint::Medium),
19478 42.0
19479 );
19480 assert_eq!(app.status_msg, "Pane splits reset");
19481 }
19482
19483 #[test]
19484 fn mouse_scroll_over_splitter_adjusts_two_pane_ratio() {
19485 let mut app = new_app(ViewMode::Main, 0);
19486 app.reset_pane_split_state();
19487 let _ = render_app(&app, 100, 24);
19488 let first_hit_box = super::splitter_hit_boxes(&app, 100, 24)
19489 .into_iter()
19490 .next()
19491 .expect("main view should expose a two-pane splitter");
19492
19493 app.update(mouse(
19494 MouseEventKind::ScrollUp,
19495 first_hit_box.rect.x,
19496 first_hit_box.rect.y.saturating_add(1),
19497 ));
19498 assert_eq!(
19499 super::pane_split_state().two_pane_list_pct(Breakpoint::Medium),
19500 46.0
19501 );
19502
19503 let second_hit_box = super::splitter_hit_boxes(&app, 100, 24)
19504 .into_iter()
19505 .next()
19506 .expect("main view should still expose a splitter after resize");
19507
19508 app.update(mouse(
19509 MouseEventKind::ScrollDown,
19510 second_hit_box.rect.x,
19511 second_hit_box.rect.y.saturating_add(1),
19512 ));
19513 assert_eq!(
19514 super::pane_split_state().two_pane_list_pct(Breakpoint::Medium),
19515 42.0
19516 );
19517 }
19518
19519 #[test]
19520 fn mouse_click_on_splitter_nudges_ratio_and_updates_focus() {
19521 let mut app = new_app(ViewMode::Main, 0);
19522 app.reset_pane_split_state();
19523 let _ = render_app(&app, 100, 24);
19524 let hit_box = super::splitter_hit_boxes(&app, 100, 24)
19525 .into_iter()
19526 .next()
19527 .expect("main view should expose a two-pane splitter");
19528 let right_edge = hit_box
19529 .rect
19530 .x
19531 .saturating_add(hit_box.rect.width.saturating_sub(1));
19532
19533 app.update(mouse(
19534 MouseEventKind::Down(MouseButton::Left),
19535 right_edge,
19536 hit_box.rect.y.saturating_add(1),
19537 ));
19538 assert_eq!(app.focus, FocusPane::Detail);
19539 assert_eq!(
19540 super::pane_split_state().two_pane_list_pct(Breakpoint::Medium),
19541 38.0
19542 );
19543 }
19544
19545 #[test]
19548 fn token_status_colours_are_distinct() {
19549 let open = tokens::status_fg("open");
19550 let prog = tokens::status_fg("in_progress");
19551 let blk = tokens::status_fg("blocked");
19552 let cls = tokens::status_fg("closed");
19553 assert_ne!(open, prog);
19554 assert_ne!(open, blk);
19555 assert_ne!(open, cls);
19556 assert_ne!(prog, blk);
19557 assert_ne!(prog, cls);
19558 assert_ne!(blk, cls);
19559 }
19560
19561 #[test]
19562 fn token_priority_colours_descend_urgency() {
19563 assert_ne!(tokens::priority_fg(0), tokens::priority_fg(3));
19565 assert_ne!(tokens::priority_fg(0), tokens::priority_fg(4));
19566 }
19567
19568 #[test]
19569 fn token_status_style_returns_correct_fg() {
19570 let open_style = tokens::status_style("open");
19571 assert_eq!(open_style.fg, Some(tokens::status_fg("open")));
19572
19573 let closed_style = tokens::status_style("closed");
19574 assert_eq!(closed_style.fg, Some(tokens::status_fg("closed")));
19575
19576 let unknown = tokens::status_style("whatever");
19577 assert_eq!(unknown.fg, Some(tokens::FG_DIM));
19578 }
19579
19580 #[test]
19581 fn token_header_is_bold() {
19582 let h = tokens::header();
19583 assert!(h.attrs.is_some_and(|a| a.contains(ftui::StyleFlags::BOLD)));
19584 }
19585
19586 #[test]
19587 fn token_selected_has_highlight_bg() {
19588 let s = tokens::selected();
19589 assert_eq!(s.bg, Some(tokens::BG_HIGHLIGHT));
19590 }
19591
19592 #[test]
19593 fn token_chip_style_has_semantic_background() {
19594 let style = tokens::chip_style(SemanticTone::Warning);
19595 assert_eq!(style.fg, Some(tokens::FG_WARNING));
19596 assert_eq!(style.bg, Some(tokens::BG_SURFACE_WARNING));
19597 }
19598
19599 #[test]
19600 fn token_focused_border_differs_from_unfocused() {
19601 let focused = tokens::panel_border_for(SemanticTone::Accent, true);
19602 let unfocused = tokens::panel_border_for(SemanticTone::Accent, false);
19603 assert_ne!(focused.fg, unfocused.fg);
19604 }
19605
19606 use super::Breakpoint;
19612 use super::tokens;
19613
19614 fn header_for_width(app: &BvrApp, width: u16) -> String {
19616 build_header_text(app, width).lines()[0].to_plain_text()
19617 }
19618
19619 #[test]
19620 fn snapshot_narrow_header_is_compact() {
19621 let app = new_app(ViewMode::Main, 0);
19622 let h = header_for_width(&app, 60);
19623 assert!(h.contains("bvr"), "header should contain 'bvr'");
19624 assert!(
19625 h.contains("1 Main"),
19626 "narrow header should show the main tab"
19627 );
19628 assert!(
19629 !h.contains("Esc back/quit"),
19630 "narrow header should remain compact"
19631 );
19632 }
19633
19634 #[test]
19635 fn snapshot_medium_header_is_full() {
19636 let app = new_app(ViewMode::Main, 0);
19637 let h = header_for_width(&app, 100);
19638 assert!(h.contains("b Board"), "medium header should show board tab");
19639 assert!(
19640 h.contains("i Insights"),
19641 "medium header should show insights tab"
19642 );
19643 assert!(h.contains("mode=Main"), "medium header should show mode=");
19644 assert!(h.contains("focus=list"), "medium header should show focus=");
19645 assert!(
19646 h.contains("issues=3/3"),
19647 "medium header should show issues metric"
19648 );
19649 }
19650
19651 #[test]
19652 fn snapshot_wide_header_is_full() {
19653 let app = new_app(ViewMode::Main, 0);
19654 let h = header_for_width(&app, 140);
19655 assert!(
19656 h.contains("[ Labels"),
19657 "wide header should expose secondary tabs"
19658 );
19659 assert!(h.contains("] Flow"), "wide header should expose flow tab");
19660 assert!(
19661 h.contains("sort=default"),
19662 "wide header should show sort metric"
19663 );
19664 assert!(
19665 h.ends_with(" |"),
19666 "wide header should preserve trailing delimiter"
19667 );
19668 }
19669
19670 #[test]
19671 fn snapshot_narrow_header_keeps_active_non_primary_mode_visible() {
19672 let app = new_app(ViewMode::Sprint, 0);
19673 let h = header_for_width(&app, 60);
19674 assert!(
19675 h.contains("S Sprint"),
19676 "narrow header should keep active tab visible"
19677 );
19678 }
19679
19680 #[test]
19681 fn help_overlay_mentions_splitter_resize_controls() {
19682 let app = new_app(ViewMode::Main, 0);
19683 let help = app.help_overlay_text(120);
19684 assert!(help.contains("Ctrl+\u{2190}/\u{2192}"));
19685 assert!(help.contains("Ctrl+0"));
19686 assert!(help.contains("splitter click/scroll"));
19687 }
19688
19689 #[test]
19690 fn header_shows_metrics_pending_chip() {
19691 let mut app = new_app(ViewMode::Main, 0);
19692 app.slow_metrics_pending = true;
19693 let h = header_for_width(&app, 120);
19694 assert!(
19695 h.contains("metrics: computing..."),
19696 "header should surface pending metrics chip: {h}"
19697 );
19698 }
19699
19700 #[test]
19701 fn snapshot_list_panel_content_consistent_across_breakpoints() {
19702 let app = new_app(ViewMode::Main, 0);
19703 let text = app.list_panel_text();
19705 assert!(text.contains("Root"), "list should contain issue title");
19706 assert!(text.contains('A'), "list should contain issue ID");
19707 }
19708
19709 #[test]
19710 fn main_list_render_text_uses_rich_issue_scan_rows() {
19711 let app = new_app(ViewMode::Main, 1);
19712 let text = app.main_list_render_text(120).to_plain_text();
19713 assert!(text.contains('▸'), "selected row marker missing: {text}");
19714 assert!(text.contains("P0"), "priority badge text missing: {text}");
19715 assert!(text.contains("#01"), "triage rank missing: {text}");
19716 assert!(text.contains("OPEN"), "status badge text missing: {text}");
19717 assert!(text.contains("⊘1"), "blocker indicator missing: {text}");
19718 assert!(text.contains("B"), "selected issue id missing: {text}");
19719 assert!(
19720 text.contains("pr#2"),
19721 "pagerank rank signal missing: {text}"
19722 );
19723 }
19724
19725 #[test]
19726 fn main_list_render_text_adapts_rows_to_narrow_width() {
19727 let app = new_app(ViewMode::Main, 1);
19728 let text = app.main_list_render_text(48).to_plain_text();
19729 assert!(text.contains("▸"), "selected row marker missing: {text}");
19730 assert!(text.contains("blocked"), "state chip missing: {text}");
19731 assert!(text.contains("Dependent"), "title missing: {text}");
19732 assert!(
19733 !text.contains("repo:"),
19734 "narrow variant should drop repo metadata: {text}"
19735 );
19736 }
19737
19738 #[test]
19739 fn main_list_empty_state_is_recovery_oriented() {
19740 let mut app = new_app(ViewMode::Main, 0);
19741 app.modal_repo_filter = Some("missing".to_string());
19742
19743 let text = app.main_list_render_text(90).to_plain_text();
19744 assert!(text.contains("No issues in the current triage slice"));
19745 assert!(
19746 text.contains("repo=missing"),
19747 "scope should mention repo: {text}"
19748 );
19749 assert!(text.contains("Recover:"), "recovery hint missing: {text}");
19750 }
19751
19752 #[test]
19753 fn main_list_search_no_hits_keeps_guidance_visible() {
19754 let mut app = new_app(ViewMode::Main, 0);
19755 app.main_search_query = "zzz".to_string();
19756
19757 let text = app.main_list_render_text(90).to_plain_text();
19758 assert!(text.contains("Matches: none in visible issues"));
19759 assert!(
19760 text.contains("refine /query"),
19761 "search guidance missing: {text}"
19762 );
19763 }
19764
19765 #[test]
19766 fn graph_list_render_text_uses_metric_strips_and_header() {
19767 let app = new_app(ViewMode::Graph, 0);
19768 let text = app.graph_list_render_text(90).to_plain_text();
19769 assert!(text.contains("Nodes"), "graph header missing: {text}");
19770 assert!(text.contains("PR "), "metric strip label missing: {text}");
19771 assert!(
19772 text.contains("↓") || text.contains("⊘"),
19773 "blocker indicators missing: {text}"
19774 );
19775 }
19776
19777 #[test]
19778 fn snapshot_detail_panel_content_consistent_across_breakpoints() {
19779 let app = new_app(ViewMode::Main, 0);
19780 let text = app.detail_panel_text();
19781 assert!(!text.is_empty(), "detail should have content");
19782 }
19783
19784 #[test]
19785 fn main_detail_includes_rich_sections() {
19786 let app = new_app(ViewMode::Main, 0);
19787 let text = app.detail_panel_text();
19788
19789 assert!(text.contains("Triage Snapshot:"));
19790 assert!(text.contains("Graph Signals:"));
19791 assert!(text.contains("Design Notes:"));
19792 assert!(text.contains("Recent Comments (2):"));
19793 assert!(text.contains("History Summary"));
19794 }
19795
19796 #[test]
19797 fn main_detail_render_text_uses_label_chips() {
19798 let app = new_app(ViewMode::Main, 0);
19799 let text = app.issue_detail_render_text().to_plain_text();
19800 assert!(text.contains("[core]"), "label chip missing: {text}");
19801 assert!(
19802 text.contains("[parity]"),
19803 "second label chip missing: {text}"
19804 );
19805 }
19806
19807 #[test]
19808 fn main_detail_render_text_uses_modular_cockpit_sections() {
19809 let app = new_app(ViewMode::Main, 0);
19810 let text = app.issue_detail_render_text().to_plain_text();
19811 assert!(text.contains("Summary"), "summary module missing: {text}");
19812 assert!(text.contains("Signals"), "signals module missing: {text}");
19813 assert!(
19814 text.contains("Dependencies"),
19815 "dependencies module missing: {text}"
19816 );
19817 assert!(
19818 text.contains("Action:"),
19819 "action-first summary line missing: {text}"
19820 );
19821 }
19822
19823 #[test]
19824 fn main_detail_marks_blocked_issue_and_dependency_map() {
19825 let app = new_app(ViewMode::Main, 1);
19826 let text = app.detail_panel_text();
19827
19828 assert!(text.contains("State: blocked"));
19829 assert!(text.contains("Dependency Map:"));
19830 assert!(text.contains("upstream: A"));
19831 assert!(text.contains("open gate: A"));
19832 }
19833
19834 #[test]
19835 fn snapshot_board_mode_list_mentions_grouping() {
19836 let app = new_app(ViewMode::Board, 0);
19837 let text = app.list_panel_text();
19838 assert!(
19839 text.contains("Grouping"),
19840 "board list should mention grouping"
19841 );
19842 }
19843
19844 #[test]
19845 fn snapshot_each_view_mode_has_content() {
19846 for mode in [
19847 ViewMode::Main,
19848 ViewMode::Board,
19849 ViewMode::Insights,
19850 ViewMode::Graph,
19851 ViewMode::History,
19852 ] {
19853 let app = new_app(mode, 0);
19854 let list = app.list_panel_text();
19855 let detail = app.detail_panel_text();
19856 assert!(!list.is_empty(), "{mode:?} list panel should not be empty");
19858 assert!(
19859 !detail.is_empty(),
19860 "{mode:?} detail panel should not be empty"
19861 );
19862 }
19863 }
19864
19865 #[test]
19866 fn snapshot_deterministic_text_across_calls() {
19867 let app = new_app(ViewMode::Main, 0);
19868 let list1 = app.list_panel_text();
19869 let list2 = app.list_panel_text();
19870 assert_eq!(list1, list2, "list text should be deterministic");
19871 let detail1 = app.detail_panel_text();
19872 let detail2 = app.detail_panel_text();
19873 assert_eq!(detail1, detail2, "detail text should be deterministic");
19874 }
19875
19876 #[test]
19879 fn help_g_scrolls_to_top() {
19880 let mut app = new_app(ViewMode::Main, 0);
19881 app.show_help = true;
19882 app.help_scroll_offset = 50;
19883
19884 app.update(key(KeyCode::Char('g')));
19885 assert_eq!(app.help_scroll_offset, 0);
19886 assert!(app.show_help, "g should scroll not close");
19887 }
19888
19889 #[test]
19890 fn help_big_g_scrolls_to_bottom() {
19891 let mut app = new_app(ViewMode::Main, 0);
19892 app.show_help = true;
19893 app.help_scroll_offset = 0;
19894
19895 app.update(key(KeyCode::Char('G')));
19896 assert_eq!(app.help_scroll_offset, 999);
19897 assert!(app.show_help, "G should scroll not close");
19898 }
19899
19900 #[test]
19901 fn help_home_scrolls_to_top() {
19902 let mut app = new_app(ViewMode::Main, 0);
19903 app.show_help = true;
19904 app.help_scroll_offset = 30;
19905
19906 app.update(key(KeyCode::Home));
19907 assert_eq!(app.help_scroll_offset, 0);
19908 assert!(app.show_help);
19909 }
19910
19911 #[test]
19912 fn help_end_scrolls_to_bottom() {
19913 let mut app = new_app(ViewMode::Main, 0);
19914 app.show_help = true;
19915 app.help_scroll_offset = 0;
19916
19917 app.update(key(KeyCode::End));
19918 assert_eq!(app.help_scroll_offset, 999);
19919 assert!(app.show_help);
19920 }
19921
19922 #[test]
19923 fn help_q_closes_help() {
19924 let mut app = new_app(ViewMode::Main, 0);
19925 app.show_help = true;
19926 app.help_scroll_offset = 10;
19927 app.focus_before_help = FocusPane::List;
19928
19929 app.update(key(KeyCode::Char('q')));
19930 assert!(!app.show_help, "q should close help");
19931 assert_eq!(app.help_scroll_offset, 0, "scroll should reset on close");
19932 }
19933
19934 #[test]
19935 fn help_f1_closes_help() {
19936 let mut app = new_app(ViewMode::Main, 0);
19937 app.show_help = true;
19938
19939 app.update(key(KeyCode::F(1)));
19940 assert!(!app.show_help, "F1 should close help");
19941 }
19942
19943 #[test]
19946 fn key_trace_records_state_transitions() {
19947 let mut app = new_app(ViewMode::Main, 0);
19948 assert!(app.key_trace.is_empty());
19949
19950 app.update(key(KeyCode::Char('j')));
19951 app.update(key(KeyCode::Char('b')));
19952 app.update(key(KeyCode::Char('j')));
19953
19954 assert_eq!(app.key_trace.len(), 3);
19955
19956 assert_eq!(app.key_trace[0].key, "Char('j')");
19958 assert_eq!(app.key_trace[0].mode, ViewMode::Main);
19959
19960 assert_eq!(app.key_trace[1].mode, ViewMode::Board);
19962
19963 assert_eq!(app.key_trace[2].mode, ViewMode::Board);
19965 }
19966
19967 #[test]
19968 fn key_trace_captures_filter_changes() {
19969 let mut app = new_app(ViewMode::Main, 0);
19970
19971 app.update(key(KeyCode::Char('o')));
19972 assert_eq!(app.key_trace.last().unwrap().filter, ListFilter::Open);
19973
19974 app.update(key(KeyCode::Char('a')));
19975 assert_eq!(app.key_trace.last().unwrap().filter, ListFilter::All);
19976 }
19977
19978 #[test]
19979 fn actionable_text_uses_legacy_summary_shape() {
19980 let mut app = new_app(ViewMode::Main, 0);
19981 app.update(key(KeyCode::Char('a')));
19982
19983 let list = app.list_panel_text();
19984 assert!(list.contains("ACTIONABLE ITEMS"));
19985 assert!(list.contains("TRACK 1"));
19986 assert!(list.contains("RECOMMENDED:"));
19987
19988 app.update(key(KeyCode::Tab));
19989 let detail = app.detail_panel_text();
19990 assert!(detail.contains("TRACK 1"));
19991 assert!(detail.contains("Claim:"));
19992 assert!(detail.contains("Highest impact:"));
19993 }
19994
19995 #[test]
19996 fn empty_lane_visibility_3state_cycle() {
19997 let v = EmptyLaneVisibility::Auto;
19998 assert_eq!(v.next(), EmptyLaneVisibility::ShowAll);
19999 assert_eq!(v.next().next(), EmptyLaneVisibility::HideEmpty);
20000 assert_eq!(v.next().next().next(), EmptyLaneVisibility::Auto);
20001 }
20002
20003 #[test]
20004 fn empty_lane_auto_shows_for_status_hides_for_others() {
20005 let auto = EmptyLaneVisibility::Auto;
20006 assert!(auto.should_show_empty(BoardGrouping::Status));
20007 assert!(!auto.should_show_empty(BoardGrouping::Priority));
20008 assert!(!auto.should_show_empty(BoardGrouping::Type));
20009 }
20010
20011 #[test]
20012 fn help_overlay_text_switches_column_layout_at_width_thresholds() {
20013 let app = new_app(ViewMode::Main, 0);
20014
20015 let narrow = app.help_overlay_text(70);
20016 let medium = app.help_overlay_text(80);
20017 let wide = app.help_overlay_text(120);
20018
20019 assert!(!narrow.lines().any(|line| line.contains(" | ")));
20020 assert!(medium.lines().any(|line| line.matches(" | ").count() == 1));
20021 assert!(wide.lines().any(|line| line.matches(" | ").count() >= 2));
20022
20023 for section in [
20024 "[Navigation]",
20025 "[Views]",
20026 "[Filters]",
20027 "[Search]",
20028 "[Actions]",
20029 "[History]",
20030 "[Board]",
20031 "[Insights]",
20032 "[Global]",
20033 ] {
20034 assert!(wide.contains(section), "wide help should include {section}");
20035 }
20036 }
20037
20038 #[test]
20039 fn help_overlay_text_keeps_critical_shortcuts_visible_in_compact_mode() {
20040 let app = new_app(ViewMode::Main, 0);
20041 let text = app.help_overlay_text(70);
20042
20043 for snippet in [
20044 "Toggle actionable mode",
20045 "Filter: open only",
20046 "Toggle file tree",
20047 "Ctrl+R/F5",
20048 "Quit immediately",
20049 ] {
20050 assert!(
20051 text.contains(snippet),
20052 "compact help should include {snippet}"
20053 );
20054 }
20055 }
20056
20057 #[test]
20060 fn history_file_tree_builds_nested_directories_and_root_files() {
20061 let app = history_app_with_git_cache(HistoryViewMode::Git, 0);
20062 let nodes = app.history_file_tree_nodes();
20063
20064 assert_eq!(nodes.first().map(|node| node.name.as_str()), Some("src"));
20065 assert_eq!(
20066 nodes
20067 .iter()
20068 .filter(|node| !node.is_dir)
20069 .map(|node| node.name.as_str())
20070 .collect::<Vec<_>>(),
20071 vec!["Cargo.toml", "README.md"]
20072 );
20073
20074 let src = nodes
20075 .iter()
20076 .find(|node| node.path == "src")
20077 .expect("src root node should exist");
20078 assert_eq!(src.change_count, 3);
20079 assert_eq!(
20080 src.children
20081 .iter()
20082 .map(|node| node.name.as_str())
20083 .collect::<Vec<_>>(),
20084 vec!["core", "ui"]
20085 );
20086
20087 let ui = src
20088 .children
20089 .iter()
20090 .find(|node| node.path == "src/ui")
20091 .expect("src/ui directory should exist");
20092 assert_eq!(ui.change_count, 2);
20093 assert_eq!(
20094 ui.children
20095 .iter()
20096 .map(|node| node.name.as_str())
20097 .collect::<Vec<_>>(),
20098 vec!["app.rs", "detail.rs"]
20099 );
20100 }
20101
20102 #[test]
20103 fn history_file_tree_filter_applies_to_git_view_and_panel_text() {
20104 let mut app = history_app_with_git_cache(HistoryViewMode::Git, 0);
20105 app.history_show_file_tree = true;
20106 app.history_file_tree_focus = true;
20107 app.history_file_tree_cursor = app
20108 .history_flat_file_list()
20109 .iter()
20110 .position(|entry| entry.path == "src/ui")
20111 .expect("src/ui entry should exist");
20112
20113 app.file_tree_toggle_or_filter();
20114
20115 assert_eq!(app.history_file_tree_filter.as_deref(), Some("src/ui"));
20116 assert_eq!(app.history_git_visible_commit_indices(), vec![0]);
20117
20118 let panel = app.file_tree_panel_text();
20119 assert!(panel.contains("Filter: src/ui"));
20120 assert!(panel.contains("src/"));
20121 assert!(panel.contains("ui/"));
20122 assert!(panel.contains("app.rs"));
20123 }
20124
20125 #[test]
20126 fn history_file_tree_filter_repositions_hidden_bead_selection() {
20127 let mut app = history_app_with_git_cache(HistoryViewMode::Bead, 1);
20128 app.history_show_file_tree = true;
20129 app.history_file_tree_cursor = app
20130 .history_flat_file_list()
20131 .iter()
20132 .position(|entry| entry.path == "src/ui")
20133 .expect("src/ui entry should exist");
20134
20135 app.file_tree_toggle_or_filter();
20136
20137 assert_eq!(selected_issue_id(&app), "A");
20138 assert_eq!(app.history_visible_issue_indices(), vec![0]);
20139 assert_eq!(
20140 app.selected_history_bead_commit()
20141 .map(|commit| commit.short_sha),
20142 Some("aaaa111".to_string())
20143 );
20144 }
20145
20146 #[test]
20147 fn history_file_tree_respects_confidence_threshold() {
20148 let mut app = history_app_with_git_cache(HistoryViewMode::Git, 0);
20149 app.history_confidence_index = 3;
20150
20151 let paths = app
20152 .history_flat_file_list()
20153 .into_iter()
20154 .filter(|entry| !entry.is_dir)
20155 .map(|entry| entry.path)
20156 .collect::<Vec<_>>();
20157
20158 assert!(paths.contains(&"src/ui/app.rs".to_string()));
20159 assert!(paths.contains(&"src/ui/detail.rs".to_string()));
20160 assert!(paths.contains(&"README.md".to_string()));
20161 assert!(!paths.contains(&"src/core/graph.rs".to_string()));
20162 assert!(!paths.contains(&"Cargo.toml".to_string()));
20163 }
20164
20165 #[test]
20166 fn history_escape_closes_file_tree_before_leaving_history() {
20167 let mut app = history_app_with_git_cache(HistoryViewMode::Bead, 0);
20168 app.mode = ViewMode::History;
20169 app.mode_before_history = ViewMode::Main;
20170 app.history_show_file_tree = true;
20171 app.history_file_tree_focus = true;
20172
20173 app.update(key(KeyCode::Escape));
20174 assert!(matches!(app.mode, ViewMode::History));
20175 assert!(!app.history_show_file_tree);
20176 assert!(!app.history_file_tree_focus);
20177
20178 app.update(key(KeyCode::Escape));
20179 assert!(matches!(app.mode, ViewMode::Main));
20180 }
20181
20182 #[test]
20183 fn history_f_toggles_file_tree() {
20184 let mut app = new_app(ViewMode::History, 0);
20185 app.mode = ViewMode::History;
20186 assert!(!app.history_show_file_tree);
20187
20188 app.update(key(KeyCode::Char('f')));
20189 assert!(app.history_show_file_tree);
20190 assert!(!app.history_status_msg.is_empty());
20191
20192 app.update(key(KeyCode::Char('f')));
20193 assert!(!app.history_show_file_tree);
20194 }
20195
20196 #[test]
20197 fn history_tab_cycles_file_tree_focus() {
20198 let mut app = new_app(ViewMode::History, 0);
20199 app.mode = ViewMode::History;
20200 app.history_show_file_tree = true;
20201 app.focus = FocusPane::List;
20202
20203 app.update(key(KeyCode::Tab));
20205 assert!(app.history_file_tree_focus);
20206 assert_eq!(app.focus, FocusPane::List);
20207
20208 app.update(key(KeyCode::Tab));
20210 assert!(!app.history_file_tree_focus);
20211 assert_eq!(app.focus, FocusPane::List);
20212 }
20213
20214 #[test]
20215 fn history_standard_tab_cycles_list_middle_detail() {
20216 let mut app = new_app(ViewMode::History, 0);
20217 app.mode = ViewMode::History;
20218 record_view_size(120, 30);
20219
20220 assert_eq!(app.focus, FocusPane::List);
20221 app.update(key(KeyCode::Tab));
20222 assert_eq!(app.focus, FocusPane::Middle);
20223 app.update(key(KeyCode::Tab));
20224 assert_eq!(app.focus, FocusPane::Detail);
20225 app.update(key(KeyCode::Tab));
20226 assert_eq!(app.focus, FocusPane::List);
20227 }
20228
20229 #[test]
20230 fn history_standard_file_tree_cycle_includes_middle_before_detail() {
20231 let mut app = new_app(ViewMode::History, 0);
20232 app.mode = ViewMode::History;
20233 record_view_size(120, 30);
20234 app.history_show_file_tree = true;
20235 app.focus = FocusPane::List;
20236
20237 app.update(key(KeyCode::Tab));
20238 assert_eq!(app.focus, FocusPane::Middle);
20239 assert!(!app.history_file_tree_focus);
20240
20241 app.update(key(KeyCode::Tab));
20242 assert_eq!(app.focus, FocusPane::Detail);
20243 assert!(!app.history_file_tree_focus);
20244
20245 app.update(key(KeyCode::Tab));
20246 assert!(app.history_file_tree_focus);
20247 assert_eq!(app.focus, FocusPane::Detail);
20248
20249 app.update(key(KeyCode::Tab));
20250 assert!(!app.history_file_tree_focus);
20251 assert_eq!(app.focus, FocusPane::List);
20252 }
20253
20254 #[test]
20255 fn history_middle_navigation_moves_bead_commit_cursor() {
20256 let mut app = history_app_with_git_cache(HistoryViewMode::Bead, 0);
20257 app.mode = ViewMode::History;
20258 record_view_size(120, 30);
20259 app.focus = FocusPane::Middle;
20260
20261 app.update(key(KeyCode::Char('j')));
20262 assert_eq!(app.history_bead_commit_cursor, 1);
20263 app.update(key(KeyCode::Char('k')));
20264 assert_eq!(app.history_bead_commit_cursor, 0);
20265 }
20266
20267 #[test]
20268 fn history_middle_navigation_moves_git_related_bead_cursor() {
20269 let mut app = history_app_with_git_cache(HistoryViewMode::Git, 0);
20270 app.mode = ViewMode::History;
20271 record_view_size(120, 30);
20272 app.focus = FocusPane::Middle;
20273 if let Some(cache) = app.history_git_cache.as_mut() {
20274 cache.commit_bead_confidence.insert(
20275 "aaaa1111".to_string(),
20276 vec![("A".to_string(), 0.95), ("B".to_string(), 0.91)],
20277 );
20278 }
20279
20280 app.update(key(KeyCode::Char('j')));
20281 assert_eq!(app.history_related_bead_cursor, 1);
20282 app.update(key(KeyCode::Char('k')));
20283 assert_eq!(app.history_related_bead_cursor, 0);
20284 }
20285
20286 #[test]
20287 fn history_standard_bead_renders_three_legacy_panes() {
20288 let app = history_app_with_git_cache(HistoryViewMode::Bead, 0);
20289 let text = render_app(&app, 120, 30);
20290 assert!(text.contains("Beads With History [focus]"));
20291 assert!(text.contains("Commits"));
20292 assert!(text.contains("Commit Details"));
20293 }
20294
20295 #[test]
20296 fn history_standard_git_renders_three_legacy_panes() {
20297 let app = history_app_with_git_cache(HistoryViewMode::Git, 0);
20298 let text = render_app(&app, 120, 30);
20299 assert!(text.contains("Commits [focus]"));
20300 assert!(text.contains("Related Beads"));
20301 assert!(text.contains("Commit Details"));
20302 }
20303
20304 #[test]
20305 fn history_wide_bead_renders_timeline_pane() {
20306 let app = history_app_with_git_cache(HistoryViewMode::Bead, 0);
20307 let text = render_app(&app, 160, 30);
20308 assert!(text.contains("Beads With History [focus]"));
20309 assert!(text.contains("Timeline: A"));
20310 assert!(text.contains("Commits"));
20311 assert!(text.contains("Commit Details"));
20312 }
20313
20314 #[test]
20315 fn history_timeline_text_uses_legacy_summary_when_git_history_exists() {
20316 let mut app = history_app_with_git_cache(HistoryViewMode::Bead, 0);
20317 {
20318 let history = app
20319 .history_git_cache
20320 .as_mut()
20321 .and_then(|cache| cache.histories.get_mut("A"))
20322 .expect("history A present");
20323 history.milestones.created = Some(HistoryEventCompat {
20324 bead_id: "A".to_string(),
20325 event_type: "created".to_string(),
20326 timestamp: "2026-01-01T00:00:00Z".to_string(),
20327 commit_sha: String::new(),
20328 commit_message: String::new(),
20329 author: "Alice".to_string(),
20330 author_email: "alice@example.com".to_string(),
20331 });
20332 history.milestones.closed = Some(HistoryEventCompat {
20333 bead_id: "A".to_string(),
20334 event_type: "closed".to_string(),
20335 timestamp: "2026-01-04T00:00:00Z".to_string(),
20336 commit_sha: String::new(),
20337 commit_message: String::new(),
20338 author: "Alice".to_string(),
20339 author_email: "alice@example.com".to_string(),
20340 });
20341 history.cycle_time = Some(HistoryCycleCompat {
20342 claim_to_close: Some("2d 0h 0m".to_string()),
20343 create_to_close: Some("3d 0h 0m".to_string()),
20344 create_to_claim: Some("1d 0h 0m".to_string()),
20345 });
20346 }
20347
20348 let text = app.history_timeline_text(48, 12);
20349 assert!(text.contains("Timeline: A"));
20350 assert!(text.contains("3d cycle"));
20351 assert!(text.contains("Cycle: 3d"));
20352 assert!(text.contains("Avg confidence"));
20353 }
20354
20355 #[test]
20356 fn history_timeline_text_renders_legacy_event_and_commit_rows() {
20357 let mut app = history_app_with_git_cache(HistoryViewMode::Bead, 0);
20358 {
20359 let history = app
20360 .history_git_cache
20361 .as_mut()
20362 .and_then(|cache| cache.histories.get_mut("A"))
20363 .expect("history A present");
20364 history.milestones.created = Some(HistoryEventCompat {
20365 bead_id: "A".to_string(),
20366 event_type: "created".to_string(),
20367 timestamp: "2026-01-01T00:00:00Z".to_string(),
20368 commit_sha: String::new(),
20369 commit_message: String::new(),
20370 author: "Alice".to_string(),
20371 author_email: "alice@example.com".to_string(),
20372 });
20373 history.milestones.claimed = Some(HistoryEventCompat {
20374 bead_id: "A".to_string(),
20375 event_type: "claimed".to_string(),
20376 timestamp: "2026-01-02T00:00:00Z".to_string(),
20377 commit_sha: String::new(),
20378 commit_message: String::new(),
20379 author: "Bob Builder".to_string(),
20380 author_email: "bob@example.com".to_string(),
20381 });
20382 history.milestones.closed = Some(HistoryEventCompat {
20383 bead_id: "A".to_string(),
20384 event_type: "closed".to_string(),
20385 timestamp: "2026-01-04T00:00:00Z".to_string(),
20386 commit_sha: String::new(),
20387 commit_message: String::new(),
20388 author: "Carol".to_string(),
20389 author_email: "carol@example.com".to_string(),
20390 });
20391 history.commits = Some(vec![
20392 HistoryCommitCompat {
20393 timestamp: "2026-01-03T00:00:00Z".to_string(),
20394 ..history_commit("aaaa1111", "feat: ui wiring", 0.95, &["src/ui/app.rs"])
20395 },
20396 HistoryCommitCompat {
20397 timestamp: "2026-01-03T12:00:00Z".to_string(),
20398 ..history_commit("bbbb2222", "feat: graph core", 0.80, &["src/core/graph.rs"])
20399 },
20400 ]);
20401 }
20402
20403 let text = app.history_timeline_text(60, 14);
20404 assert!(text.contains("○ Created"));
20405 assert!(text.contains("● Claimed"));
20406 assert!(text.contains("✓ Closed"));
20407 assert!(text.contains("├─ aaaa111"));
20408 assert!(text.contains("feat: ui wiring"));
20409 }
20410
20411 #[test]
20412 fn history_detail_shows_yof_hints() {
20413 let app = new_app(ViewMode::History, 0);
20414 let detail = app.detail_panel_text();
20415 assert!(
20416 detail.contains("y: copy") || detail.contains("y:"),
20417 "history detail should mention y key"
20418 );
20419 assert!(
20420 detail.contains("f: file") || detail.contains("f:"),
20421 "history detail should mention f key"
20422 );
20423 }
20424
20425 #[test]
20426 fn history_footer_shows_key_hints() {
20427 let app = new_app(ViewMode::History, 0);
20429 assert_eq!(app.history_view_mode.label(), "bead");
20431 }
20432
20433 #[test]
20434 fn history_search_mode_defaults_to_all() {
20435 let app = new_app(ViewMode::History, 0);
20436 assert_eq!(app.history_search_mode, HistorySearchMode::All);
20437 }
20438
20439 #[test]
20440 fn history_search_mode_cycles_with_tab() {
20441 let mut app = new_app(ViewMode::History, 0);
20442 app.handle_key(KeyCode::Char('/'), Modifiers::NONE);
20444 assert!(app.history_search_active);
20445 assert_eq!(app.history_search_mode, HistorySearchMode::All);
20446
20447 app.handle_key(KeyCode::Tab, Modifiers::NONE);
20449 assert_eq!(app.history_search_mode, HistorySearchMode::Commit);
20450
20451 app.handle_key(KeyCode::Tab, Modifiers::NONE);
20453 assert_eq!(app.history_search_mode, HistorySearchMode::Sha);
20454
20455 app.handle_key(KeyCode::Tab, Modifiers::NONE);
20457 assert_eq!(app.history_search_mode, HistorySearchMode::Bead);
20458
20459 app.handle_key(KeyCode::Tab, Modifiers::NONE);
20461 assert_eq!(app.history_search_mode, HistorySearchMode::Author);
20462
20463 app.handle_key(KeyCode::Tab, Modifiers::NONE);
20465 assert_eq!(app.history_search_mode, HistorySearchMode::All);
20466 }
20467
20468 #[test]
20469 fn history_search_mode_resets_on_new_search() {
20470 let mut app = new_app(ViewMode::History, 0);
20471 app.handle_key(KeyCode::Char('/'), Modifiers::NONE);
20473 app.handle_key(KeyCode::Tab, Modifiers::NONE);
20474 assert_eq!(app.history_search_mode, HistorySearchMode::Commit);
20475
20476 app.handle_key(KeyCode::Enter, Modifiers::NONE);
20478 assert!(!app.history_search_active);
20479 assert_eq!(app.history_search_mode, HistorySearchMode::Commit);
20481
20482 app.handle_key(KeyCode::Char('/'), Modifiers::NONE);
20484 assert_eq!(app.history_search_mode, HistorySearchMode::All);
20485 }
20486
20487 #[test]
20488 fn history_search_mode_label_shown_in_list_text() {
20489 let mut app = new_app(ViewMode::History, 0);
20490 app.handle_key(KeyCode::Char('/'), Modifiers::NONE);
20491 app.handle_key(KeyCode::Char('t'), Modifiers::NONE);
20492
20493 let text = app.history_list_text();
20494 assert!(
20495 text.contains("[all]"),
20496 "list text should show search mode label, got: {text}"
20497 );
20498
20499 app.handle_key(KeyCode::Tab, Modifiers::NONE);
20501 app.handle_key(KeyCode::Tab, Modifiers::NONE);
20502 app.handle_key(KeyCode::Tab, Modifiers::NONE);
20503 let text = app.history_list_text();
20504 assert!(
20505 text.contains("[bead]"),
20506 "list text should show bead mode label, got: {text}"
20507 );
20508 }
20509
20510 #[test]
20511 fn history_search_mode_bead_filters_by_id_and_title_only() {
20512 let mut app = new_app(ViewMode::History, 0);
20513 app.handle_key(KeyCode::Char('/'), Modifiers::NONE);
20515 app.handle_key(KeyCode::Tab, Modifiers::NONE);
20517 app.handle_key(KeyCode::Tab, Modifiers::NONE);
20518 app.handle_key(KeyCode::Tab, Modifiers::NONE);
20519 assert_eq!(app.history_search_mode, HistorySearchMode::Bead);
20520
20521 for ch in "root".chars() {
20523 app.handle_key(KeyCode::Char(ch), Modifiers::NONE);
20524 }
20525
20526 let visible = app.history_visible_issue_indices();
20527 assert!(
20529 !visible.is_empty(),
20530 "bead mode search for 'root' should match issue A by title"
20531 );
20532
20533 app.handle_key(KeyCode::Escape, Modifiers::NONE);
20535 app.handle_key(KeyCode::Char('/'), Modifiers::NONE);
20536 app.handle_key(KeyCode::Tab, Modifiers::NONE);
20538 app.handle_key(KeyCode::Tab, Modifiers::NONE);
20539 app.handle_key(KeyCode::Tab, Modifiers::NONE);
20540 for ch in "open".chars() {
20541 app.handle_key(KeyCode::Char(ch), Modifiers::NONE);
20542 }
20543 let visible_status = app.history_visible_issue_indices();
20544
20545 app.handle_key(KeyCode::Escape, Modifiers::NONE);
20547 app.handle_key(KeyCode::Char('/'), Modifiers::NONE);
20548 assert_eq!(app.history_search_mode, HistorySearchMode::All);
20550 for ch in "open".chars() {
20551 app.handle_key(KeyCode::Char(ch), Modifiers::NONE);
20552 }
20553 let visible_all = app.history_visible_issue_indices();
20554
20555 assert!(
20557 visible_all.len() >= visible_status.len(),
20558 "All mode should match at least as many as Bead mode"
20559 );
20560 }
20561
20562 #[test]
20563 fn history_git_search_modes_filter_commit_sha_and_author_fields() {
20564 let mut app = history_app_with_git_cache(HistoryViewMode::Git, 0);
20565 if let Some(cache) = app.history_git_cache.as_mut() {
20566 cache.commits[0].author = "Alice Example".to_string();
20567 cache.commits[0].author_email = "alice@example.com".to_string();
20568 cache.commits[1].author = "Carol Example".to_string();
20569 cache.commits[1].author_email = "carol@example.com".to_string();
20570 cache.commits[2].author = "Bob Example".to_string();
20571 cache.commits[2].author_email = "bob@example.com".to_string();
20572 cache.commits[3].author = "Bob Example".to_string();
20573 cache.commits[3].author_email = "bob@example.com".to_string();
20574 }
20575
20576 app.history_search_mode = HistorySearchMode::Commit;
20577 app.history_search_query = "graph".to_string();
20578 assert_eq!(app.history_search_matches(), vec![1]);
20579
20580 app.history_search_mode = HistorySearchMode::Sha;
20581 app.history_search_query = "cccc".to_string();
20582 assert_eq!(app.history_search_matches(), vec![2]);
20583
20584 app.history_search_mode = HistorySearchMode::Author;
20585 app.history_search_query = "bob".to_string();
20586 assert_eq!(app.history_search_matches(), vec![2, 3]);
20587 }
20588
20589 #[test]
20590 fn history_git_search_matches_respect_file_tree_filter() {
20591 let mut app = history_app_with_git_cache(HistoryViewMode::Git, 0);
20592 app.history_search_mode = HistorySearchMode::Commit;
20593 app.history_search_query = "feat".to_string();
20594
20595 assert_eq!(app.history_search_matches(), vec![0, 1]);
20596
20597 app.history_file_tree_filter = Some("src/ui".to_string());
20598 assert_eq!(app.history_search_matches(), vec![0]);
20599 }
20600
20601 #[test]
20602 fn history_search_mode_enum_coverage() {
20603 assert_eq!(HistorySearchMode::All.label(), "all");
20605 assert_eq!(HistorySearchMode::Commit.label(), "msg");
20606 assert_eq!(HistorySearchMode::Sha.label(), "sha");
20607 assert_eq!(HistorySearchMode::Bead.label(), "bead");
20608 assert_eq!(HistorySearchMode::Author.label(), "author");
20609
20610 let mut mode = HistorySearchMode::All;
20612 for _ in 0..5 {
20613 mode = mode.cycle();
20614 }
20615 assert_eq!(mode, HistorySearchMode::All);
20616 }
20617
20618 #[test]
20619 fn history_view_mode_indicator_matches_legacy_icons() {
20620 assert_eq!(HistoryViewMode::Bead.indicator(), "◈ Beads");
20621 assert_eq!(HistoryViewMode::Git.indicator(), "◉ Git");
20622 }
20623
20624 #[test]
20625 fn history_status_line_shows_legacy_mode_indicator() {
20626 let bead_view =
20627 render_debug_view(sample_issues(), "history", 100, 30).expect("history view renders");
20628 assert!(bead_view.contains("mode=History ◈ Beads"));
20629
20630 let mut app = new_app(ViewMode::History, 0);
20631 app.handle_key(KeyCode::Char('v'), Modifiers::NONE);
20632 let mut pool = ftui::GraphemePool::default();
20633 let mut frame = ftui::render::frame::Frame::new(100, 30, &mut pool);
20634 app.view(&mut frame);
20635 let git_view = buffer_to_text(&frame.buffer, &pool);
20636 assert!(git_view.contains("mode=History ◉ Git"));
20637 }
20638
20639 #[test]
20640 fn compact_history_duration_label_prefers_first_nonzero_unit() {
20641 assert_eq!(compact_history_duration_label("3d 0h 0m"), "3d");
20642 assert_eq!(compact_history_duration_label("0d 5h 0m"), "5h");
20643 assert_eq!(compact_history_duration_label("0d 0h 15m"), "15m");
20644 }
20645
20646 #[test]
20647 fn legacy_history_author_initials_match_go_contract() {
20648 assert_eq!(legacy_history_author_initials(""), "??");
20649 assert_eq!(legacy_history_author_initials("alice"), "AL");
20650 assert_eq!(legacy_history_author_initials("Alice Baker"), "AB");
20651 assert_eq!(legacy_history_author_initials("Alice Beth Carter"), "AC");
20652 }
20653
20654 #[test]
20655 fn history_compact_timeline_matches_legacy_marker_contract() {
20656 let mut app = history_app_with_git_cache(HistoryViewMode::Bead, 0);
20657 let history = {
20658 let history = app
20659 .history_git_cache
20660 .as_mut()
20661 .and_then(|cache| cache.histories.get_mut("A"))
20662 .expect("history A present");
20663
20664 history.milestones.created = Some(HistoryEventCompat {
20665 bead_id: "A".to_string(),
20666 event_type: "created".to_string(),
20667 timestamp: "2026-01-01T00:00:00Z".to_string(),
20668 commit_sha: String::new(),
20669 commit_message: String::new(),
20670 author: "Alice".to_string(),
20671 author_email: "alice@example.com".to_string(),
20672 });
20673 history.milestones.claimed = Some(HistoryEventCompat {
20674 bead_id: "A".to_string(),
20675 event_type: "claimed".to_string(),
20676 timestamp: "2026-01-02T00:00:00Z".to_string(),
20677 commit_sha: String::new(),
20678 commit_message: String::new(),
20679 author: "Alice".to_string(),
20680 author_email: "alice@example.com".to_string(),
20681 });
20682 history.milestones.closed = Some(HistoryEventCompat {
20683 bead_id: "A".to_string(),
20684 event_type: "closed".to_string(),
20685 timestamp: "2026-01-04T00:00:00Z".to_string(),
20686 commit_sha: String::new(),
20687 commit_message: String::new(),
20688 author: "Alice".to_string(),
20689 author_email: "alice@example.com".to_string(),
20690 });
20691 history.cycle_time = Some(HistoryCycleCompat {
20692 claim_to_close: Some("2d 0h 0m".to_string()),
20693 create_to_close: Some("3d 0h 0m".to_string()),
20694 create_to_claim: Some("1d 0h 0m".to_string()),
20695 });
20696 history.clone()
20697 };
20698
20699 let text = app.history_compact_timeline_text(&history, 80);
20700 assert!(text.contains("○"));
20701 assert!(text.contains("●"));
20702 assert!(text.contains("├"));
20703 assert!(text.contains("✓"));
20704 assert!(text.contains("3d cycle"));
20705 assert!(text.contains("2 commits"));
20706 }
20707
20708 #[test]
20709 fn history_compact_timeline_collapses_many_commit_markers() {
20710 let mut app = history_app_with_git_cache(HistoryViewMode::Bead, 0);
20711 let history = {
20712 let history = app
20713 .history_git_cache
20714 .as_mut()
20715 .and_then(|cache| cache.histories.get_mut("A"))
20716 .expect("history A present");
20717
20718 history.milestones.created = Some(HistoryEventCompat {
20719 bead_id: "A".to_string(),
20720 event_type: "created".to_string(),
20721 timestamp: "2026-01-01T00:00:00Z".to_string(),
20722 commit_sha: String::new(),
20723 commit_message: String::new(),
20724 author: "Alice".to_string(),
20725 author_email: "alice@example.com".to_string(),
20726 });
20727 history.commits = Some(vec![
20728 history_commit("a1", "feat: one", 0.90, &["src/a.rs"]),
20729 history_commit("a2", "feat: two", 0.90, &["src/b.rs"]),
20730 history_commit("a3", "feat: three", 0.90, &["src/c.rs"]),
20731 history_commit("a4", "feat: four", 0.90, &["src/d.rs"]),
20732 history_commit("a5", "feat: five", 0.90, &["src/e.rs"]),
20733 history_commit("a6", "feat: six", 0.90, &["src/f.rs"]),
20734 history_commit("a7", "feat: seven", 0.90, &["src/g.rs"]),
20735 ]);
20736 history.clone()
20737 };
20738
20739 let text = app.history_compact_timeline_text(&history, 80);
20740 assert!(text.contains("…"));
20741 assert!(text.contains("7 commits"));
20742 }
20743
20744 #[test]
20745 fn history_detail_surfaces_compact_timeline_when_git_history_exists() {
20746 let mut app = history_app_with_git_cache(HistoryViewMode::Bead, 0);
20747 {
20748 let history = app
20749 .history_git_cache
20750 .as_mut()
20751 .and_then(|cache| cache.histories.get_mut("A"))
20752 .expect("history A present");
20753
20754 history.milestones.created = Some(HistoryEventCompat {
20755 bead_id: "A".to_string(),
20756 event_type: "created".to_string(),
20757 timestamp: "2026-01-01T00:00:00Z".to_string(),
20758 commit_sha: String::new(),
20759 commit_message: String::new(),
20760 author: "Alice".to_string(),
20761 author_email: "alice@example.com".to_string(),
20762 });
20763 history.milestones.closed = Some(HistoryEventCompat {
20764 bead_id: "A".to_string(),
20765 event_type: "closed".to_string(),
20766 timestamp: "2026-01-04T00:00:00Z".to_string(),
20767 commit_sha: String::new(),
20768 commit_message: String::new(),
20769 author: "Alice".to_string(),
20770 author_email: "alice@example.com".to_string(),
20771 });
20772 history.cycle_time = Some(HistoryCycleCompat {
20773 claim_to_close: Some("2d 0h 0m".to_string()),
20774 create_to_close: Some("3d 0h 0m".to_string()),
20775 create_to_claim: Some("1d 0h 0m".to_string()),
20776 });
20777 }
20778
20779 let text = app.history_detail_text();
20780 assert!(text.contains("Timeline:"));
20781 assert!(text.contains("3d cycle"));
20782 }
20783
20784 #[test]
20785 fn truncate_display_preserves_grapheme_clusters_and_cell_width() {
20786 let text = "Ame\u{301}lie 👩💻";
20787 let truncated = truncate_display(text, 6);
20788 assert_eq!(display_width(&truncated), 6);
20789 assert!(truncated.ends_with('…'));
20790 assert!(
20791 truncated.contains("e\u{301}"),
20792 "combining grapheme should stay intact"
20793 );
20794 }
20795
20796 #[test]
20797 fn fit_display_pads_to_visual_width_for_wide_graphemes() {
20798 let fitted = fit_display("界", 4);
20799 assert_eq!(display_width(&fitted), 4);
20800 assert_eq!(fitted, "界 ");
20801 }
20802
20803 #[test]
20804 fn truncate_display_handles_cjk_double_width() {
20805 let text = "日本語テスト";
20807 assert_eq!(display_width(text), 12);
20808
20809 let truncated = truncate_display(text, 7);
20810 assert!(display_width(&truncated) <= 7);
20812 assert!(truncated.ends_with('…'));
20813 }
20814
20815 #[test]
20816 fn truncate_display_handles_emoji_sequences() {
20817 let text = "👨👩👧👦 family";
20819 let truncated = truncate_display(text, 5);
20820 assert!(display_width(&truncated) <= 5);
20821 }
20822
20823 #[test]
20824 fn truncate_display_ascii_within_limit_is_unchanged() {
20825 let text = "hello";
20826 assert_eq!(truncate_display(text, 10), "hello");
20827 assert_eq!(truncate_display(text, 5), "hello");
20828 }
20829
20830 #[test]
20831 fn truncate_display_empty_string() {
20832 assert_eq!(truncate_display("", 10), "");
20833 assert_eq!(truncate_display("", 0), "");
20834 }
20835
20836 #[test]
20837 fn truncate_display_zero_width() {
20838 assert_eq!(truncate_display("hello", 0), "");
20839 }
20840
20841 #[test]
20842 fn truncate_display_width_one() {
20843 let truncated = truncate_display("hello", 1);
20844 assert_eq!(display_width(&truncated), 1);
20845 }
20846
20847 #[test]
20848 fn fit_display_cjk_truncation_and_padding() {
20849 let fitted = fit_display("世界", 6);
20851 assert_eq!(display_width(&fitted), 6);
20852 assert!(fitted.starts_with("世界"));
20853
20854 let fitted_narrow = fit_display("世界你好", 5);
20856 assert_eq!(display_width(&fitted_narrow), 5);
20857 }
20858
20859 #[test]
20860 fn center_display_with_cjk() {
20861 let centered = center_display("界", 6);
20862 assert_eq!(display_width(¢ered), 6);
20863 assert!(centered.contains("界"));
20865 }
20866
20867 #[test]
20868 fn command_hint_width_with_unicode_keys() {
20869 let hint = CommandHint {
20870 key: "⌘",
20871 desc: "cmd",
20872 };
20873 let width = command_hint_width(hint);
20874 assert_eq!(width, display_width("⌘") + 1 + display_width("cmd"));
20875 }
20876
20877 #[test]
20878 fn wrap_command_hints_preserves_groups_and_styles() {
20879 let hints = [
20880 CommandHint {
20881 key: "Tab",
20882 desc: "mode",
20883 },
20884 CommandHint {
20885 key: "/",
20886 desc: "search",
20887 },
20888 CommandHint {
20889 key: "O",
20890 desc: "edit",
20891 },
20892 ];
20893
20894 let wrapped = wrap_command_hints(&hints, 18);
20895 assert_eq!(wrapped.lines().len(), 2);
20896 assert_eq!(wrapped.lines()[0].to_plain_text(), "Tab mode");
20897 assert_eq!(wrapped.lines()[1].to_plain_text(), "/ search │ O edit");
20898
20899 let first_line = wrapped.lines()[0].spans();
20900 let second_line = wrapped.lines()[1].spans();
20901 assert_eq!(first_line[0].style, Some(tokens::footer_key()));
20902 assert_eq!(first_line[2].style, Some(tokens::footer_hint()));
20903 assert_eq!(second_line[3].style, Some(tokens::footer_sep()));
20904 }
20905
20906 #[test]
20907 fn main_footer_command_hints_wrap_across_multiple_lines() {
20908 let hints = [
20909 CommandHint {
20910 key: "b/i/g/h",
20911 desc: "modes",
20912 },
20913 CommandHint {
20914 key: "/",
20915 desc: "search",
20916 },
20917 CommandHint {
20918 key: "s",
20919 desc: "sort",
20920 },
20921 CommandHint {
20922 key: "p",
20923 desc: "hints",
20924 },
20925 CommandHint {
20926 key: "C",
20927 desc: "copy",
20928 },
20929 CommandHint {
20930 key: "x",
20931 desc: "export",
20932 },
20933 CommandHint {
20934 key: "O",
20935 desc: "edit",
20936 },
20937 ];
20938
20939 let wrapped = wrap_command_hints(&hints, 20);
20940 let plain_lines = wrapped
20941 .lines()
20942 .iter()
20943 .map(ftui::text::Line::to_plain_text)
20944 .collect::<Vec<_>>();
20945
20946 assert_eq!(
20947 plain_lines,
20948 vec![
20949 "b/i/g/h modes".to_string(),
20950 "/ search │ s sort".to_string(),
20951 "p hints │ C copy".to_string(),
20952 "x export │ O edit".to_string(),
20953 ]
20954 );
20955 }
20956
20957 #[test]
20958 fn styled_detail_summary_line_turns_status_into_chips() {
20959 let line =
20960 styled_detail_summary_line("Status: open | Priority: p1 | Type: bug | State: ready")
20961 .expect("styled summary line");
20962 assert_eq!(
20963 line.to_plain_text(),
20964 "Status: open | Priority: p1 | Type: bug | State: ready"
20965 );
20966 let spans = line.spans();
20967 assert_eq!(
20968 spans[2].style,
20969 Some(tokens::chip_style(SemanticTone::Accent))
20970 );
20971 assert_eq!(
20972 spans[6].style,
20973 Some(tokens::chip_style(SemanticTone::Warning))
20974 );
20975 assert_eq!(
20976 spans[14].style,
20977 Some(tokens::chip_style(SemanticTone::Success))
20978 );
20979 }
20980
20981 #[test]
20982 fn history_detail_renders_hyperlink_for_selected_commit() {
20983 let repo = init_temp_repo_with_remote("git@github.com:owner/repo.git");
20984 let mut app = history_app_with_git_cache(HistoryViewMode::Git, 0);
20985 app.repo_root = Some(repo.path().to_path_buf());
20986
20987 let urls = rendered_link_urls(&app, 120, 40);
20988 assert!(
20989 urls.iter()
20990 .any(|url| url == "https://github.com/owner/repo/commit/aaaa1111"),
20991 "expected commit hyperlink to be rendered, got {urls:?}"
20992 );
20993
20994 let rendered = app.history_detail_render_text().to_plain_text();
20995 assert!(
20996 rendered.contains("open selected commit (o open, right-click copy link)"),
20997 "expected inline open hint for commit hyperlink, got:\n{rendered}"
20998 );
20999 }
21000
21001 #[test]
21002 fn history_footer_shows_commit_actions_when_selected_commit_url_is_available() {
21003 let repo = init_temp_repo_with_remote("git@github.com:owner/repo.git");
21004 let mut app = history_app_with_git_cache(HistoryViewMode::Git, 0);
21005 app.repo_root = Some(repo.path().to_path_buf());
21006
21007 let rendered = render_app(&app, 120, 40);
21008 assert!(
21009 rendered.contains("y copy"),
21010 "expected history footer to advertise copy action, got:\n{rendered}"
21011 );
21012 assert!(
21013 rendered.contains("o open commit"),
21014 "expected history footer to advertise open action when a commit URL exists, got:\n{rendered}"
21015 );
21016 }
21017
21018 #[test]
21019 fn history_footer_hides_open_commit_hint_without_selected_commit_url() {
21020 let no_git_dir = tempfile::tempdir().expect("tempdir");
21024 let mut app = history_app_with_git_cache(HistoryViewMode::Git, 0);
21025 app.repo_root = Some(no_git_dir.path().to_path_buf());
21026
21027 let rendered = render_app(&app, 120, 40);
21028 assert!(
21029 rendered.contains("y copy"),
21030 "expected history footer to keep the copy action, got:\n{rendered}"
21031 );
21032 assert!(
21033 !rendered.contains("o open commit"),
21034 "expected history footer to hide open action without a commit URL, got:\n{rendered}"
21035 );
21036 }
21037
21038 #[test]
21039 fn history_footer_switches_to_file_tree_controls_when_tree_has_focus() {
21040 let mut app = history_app_with_git_cache(HistoryViewMode::Git, 0);
21041 app.history_show_file_tree = true;
21042 app.history_file_tree_focus = true;
21043
21044 let rendered = render_app(&app, 120, 40);
21045 assert!(
21046 rendered.contains("j/k tree"),
21047 "expected history footer to advertise file-tree navigation, got:\n{rendered}"
21048 );
21049 assert!(
21050 rendered.contains("Enter filter"),
21051 "expected history footer to advertise file-tree filtering, got:\n{rendered}"
21052 );
21053 assert!(
21054 rendered.contains("Esc close tree"),
21055 "expected history footer to advertise closing the file tree, got:\n{rendered}"
21056 );
21057 assert!(
21058 !rendered.contains("o open commit"),
21059 "expected generic commit action hints to be hidden while file tree owns focus, got:\n{rendered}"
21060 );
21061 }
21062
21063 #[test]
21064 fn history_file_tree_focus_blocks_view_toggle_shortcuts() {
21065 let mut app = history_app_with_git_cache(HistoryViewMode::Git, 0);
21066 app.history_show_file_tree = true;
21067 app.history_file_tree_focus = true;
21068
21069 app.update(key(KeyCode::Char('v')));
21070 assert_eq!(app.history_view_mode, HistoryViewMode::Git);
21071
21072 app.update(key(KeyCode::Char('h')));
21073 assert_eq!(app.mode, ViewMode::History);
21074 assert!(app.history_file_tree_focus);
21075 }
21076
21077 #[test]
21078 fn history_file_tree_focus_blocks_copy_and_open_shortcuts() {
21079 let repo = init_temp_repo_with_remote("git@github.com:owner/repo.git");
21080 let mut app = history_app_with_git_cache(HistoryViewMode::Git, 0);
21081 app.repo_root = Some(repo.path().to_path_buf());
21082 app.history_show_file_tree = true;
21083 app.history_file_tree_focus = true;
21084 app.history_status_msg = "File tree: j/k navigate, Enter filter, Esc close".into();
21085
21086 app.update(key(KeyCode::Char('y')));
21087 assert_eq!(
21088 app.history_status_msg,
21089 "File tree: j/k navigate, Enter filter, Esc close"
21090 );
21091
21092 app.update(key(KeyCode::Char('o')));
21093 assert_eq!(
21094 app.history_status_msg,
21095 "File tree: j/k navigate, Enter filter, Esc close"
21096 );
21097 }
21098
21099 #[test]
21100 fn history_detail_hides_open_hint_without_selected_commit_url() {
21101 let no_git_dir = tempfile::tempdir().expect("tempdir");
21102 let mut app = history_app_with_git_cache(HistoryViewMode::Git, 0);
21103 app.repo_root = Some(no_git_dir.path().to_path_buf());
21104
21105 let rendered = app.history_detail_render_text().to_plain_text();
21106 assert!(
21109 !rendered.contains("open selected commit (o open, right-click copy link)"),
21110 "expected history detail to omit the browser link affordance without a commit URL, got:\n{rendered}"
21111 );
21112 assert!(
21113 !rendered.contains("Browser Link:"),
21114 "expected no browser link line without a commit URL, got:\n{rendered}"
21115 );
21116 }
21117
21118 #[test]
21119 fn history_selected_commit_url_tracks_selected_bead_commit_cursor() {
21120 let repo = init_temp_repo_with_remote("git@github.com:owner/repo.git");
21121 let mut app = history_app_with_git_cache(HistoryViewMode::Bead, 0);
21122 app.repo_root = Some(repo.path().to_path_buf());
21123 app.history_bead_commit_cursor = 1;
21124
21125 let url = app
21126 .history_selected_commit_url()
21127 .expect("selected bead commit URL");
21128 assert!(
21129 url.ends_with("/bbbb2222"),
21130 "expected selected cursor to drive bead commit URL, got {url}"
21131 );
21132 }
21133
21134 #[test]
21135 fn history_bead_middle_enter_backtraces_to_git_commit() {
21136 let mut app = history_app_with_git_cache(HistoryViewMode::Bead, 0);
21137 app.mode = ViewMode::History;
21138 app.focus = FocusPane::Middle;
21139 app.history_bead_commit_cursor = 1;
21140
21141 let cmd = app.update(key(KeyCode::Enter));
21142
21143 assert!(matches!(cmd, Cmd::None));
21144 assert_eq!(app.mode, ViewMode::History);
21145 assert_eq!(app.history_view_mode, HistoryViewMode::Git);
21146 assert_eq!(app.focus, FocusPane::List);
21147 assert_eq!(app.history_event_cursor, 1);
21148 assert!(
21149 app.history_status_msg
21150 .contains("Backtraced to commit bbbb222")
21151 );
21152 }
21153
21154 #[test]
21155 fn history_bead_detail_shows_field_level_diff_lines() {
21156 let mut app = history_app_with_git_cache(HistoryViewMode::Bead, 0);
21157 app.mode = ViewMode::History;
21158 if let Some(cache) = app.history_git_cache.as_mut()
21159 && let Some(history) = cache.histories.get_mut("A")
21160 && let Some(commit) = history
21161 .commits
21162 .as_mut()
21163 .and_then(|commits| commits.get_mut(0))
21164 {
21165 commit.field_changes = vec![FieldChange {
21166 field: "status".to_string(),
21167 old_value: "open".to_string(),
21168 new_value: "blocked".to_string(),
21169 }];
21170 commit.bead_diff_lines = vec![
21171 "- status: open".to_string(),
21172 "+ status: blocked".to_string(),
21173 ];
21174 }
21175
21176 let detail = app.history_detail_text();
21177 assert!(detail.contains("Fields changed: status"), "got:\n{detail}");
21178 assert!(detail.contains("- status: open"), "got:\n{detail}");
21179 assert!(detail.contains("+ status: blocked"), "got:\n{detail}");
21180 }
21181
21182 #[test]
21183 fn history_git_detail_shows_selected_related_bead_change_summary() {
21184 let mut app = history_app_with_git_cache(HistoryViewMode::Git, 0);
21185 app.mode = ViewMode::History;
21186 if let Some(cache) = app.history_git_cache.as_mut()
21187 && let Some(history) = cache.histories.get_mut("A")
21188 && let Some(commit) = history
21189 .commits
21190 .as_mut()
21191 .and_then(|commits| commits.get_mut(0))
21192 {
21193 commit.field_changes = vec![FieldChange {
21194 field: "labels".to_string(),
21195 old_value: "backend".to_string(),
21196 new_value: "backend,urgent".to_string(),
21197 }];
21198 commit.bead_diff_lines = vec![
21199 "- labels: backend".to_string(),
21200 "+ labels: backend,urgent".to_string(),
21201 ];
21202 }
21203
21204 let detail = app.history_detail_text();
21205 assert!(
21206 detail.contains("SELECTED BEAD CHANGE (A):"),
21207 "got:\n{detail}"
21208 );
21209 assert!(detail.contains("Fields: labels"), "got:\n{detail}");
21210 assert!(
21211 detail.contains("+ labels: backend,urgent"),
21212 "got:\n{detail}"
21213 );
21214 }
21215
21216 #[test]
21217 fn main_detail_renders_hyperlink_for_external_issue_reference() {
21218 let mut app = new_app(ViewMode::Main, 0);
21219 app.analyzer.issues[0].external_ref = Some("https://github.com/org/repo/issues/42".into());
21220
21221 let urls = rendered_link_urls(&app, 120, 40);
21222 assert!(
21223 urls.iter()
21224 .any(|url| url == "https://github.com/org/repo/issues/42"),
21225 "expected external issue hyperlink to be rendered, got {urls:?}"
21226 );
21227
21228 let rendered = render_app(&app, 120, 40);
21229 assert!(
21230 rendered.contains("(o open, y copy)"),
21231 "expected inline external-ref action hint, got:\n{rendered}"
21232 );
21233 assert!(
21234 rendered.contains("(C copy id)"),
21235 "expected issue-id action hint, got:\n{rendered}"
21236 );
21237 assert!(
21238 rendered.contains("(w repo filter)"),
21239 "expected repo-filter action hint, got:\n{rendered}"
21240 );
21241 assert!(
21242 rendered.contains("(L label filter)"),
21243 "expected label-filter action hint, got:\n{rendered}"
21244 );
21245 assert!(
21246 rendered.contains("(t time-travel)"),
21247 "expected time-travel action hint, got:\n{rendered}"
21248 );
21249 }
21250
21251 #[test]
21252 fn graph_detail_renders_hyperlink_for_external_issue_reference() {
21253 let mut app = new_app(ViewMode::Graph, 0);
21254 app.focus = FocusPane::Detail;
21255 for issue in &mut app.analyzer.issues {
21256 issue.external_ref = Some("https://github.com/org/repo/issues/42".into());
21257 }
21258
21259 let detail = app.graph_detail_render_text();
21260 let urls = detail
21261 .lines()
21262 .iter()
21263 .flat_map(ftui::text::Line::spans)
21264 .filter_map(|span| span.link.as_deref())
21265 .collect::<Vec<_>>();
21266 assert!(
21267 urls.iter()
21268 .any(|url| *url == "https://github.com/org/repo/issues/42"),
21269 "expected graph detail hyperlink span to be rendered, got {urls:?}"
21270 );
21271
21272 let rendered = detail.to_plain_text();
21273 assert!(
21274 rendered.contains("(o open, y copy)"),
21275 "expected graph detail inline external-ref action hint, got:\n{rendered}"
21276 );
21277 }
21278
21279 #[test]
21280 fn main_detail_open_shortcut_only_activates_for_detail_focus_with_http_ref() {
21281 let mut app = new_app(ViewMode::Main, 0);
21282 assert!(!app.should_open_selected_issue_external_ref());
21283
21284 app.focus = FocusPane::Detail;
21285 assert!(!app.should_open_selected_issue_external_ref());
21286
21287 app.analyzer.issues[0].external_ref = Some("mailto:test@example.com".into());
21288 assert!(!app.should_open_selected_issue_external_ref());
21289
21290 app.analyzer.issues[0].external_ref = Some("https://github.com/org/repo/issues/42".into());
21291 assert!(app.should_open_selected_issue_external_ref());
21292 }
21293
21294 #[test]
21295 fn graph_detail_open_shortcut_only_activates_for_detail_focus_with_http_ref() {
21296 let mut app = new_app(ViewMode::Graph, 0);
21297 assert!(!app.should_open_selected_issue_external_ref());
21298
21299 app.focus = FocusPane::Detail;
21300 assert!(!app.should_open_selected_issue_external_ref());
21301
21302 for issue in &mut app.analyzer.issues {
21303 issue.external_ref = Some("mailto:test@example.com".into());
21304 }
21305 assert!(!app.should_open_selected_issue_external_ref());
21306
21307 for issue in &mut app.analyzer.issues {
21308 issue.external_ref = Some("https://github.com/org/repo/issues/42".into());
21309 }
21310 assert!(app.should_open_selected_issue_external_ref());
21311 }
21312
21313 #[test]
21314 fn board_detail_open_shortcut_only_activates_for_detail_focus_with_http_ref() {
21315 let mut app = new_app(ViewMode::Board, 0);
21316 assert!(!app.should_open_selected_issue_external_ref());
21317
21318 app.focus = FocusPane::Detail;
21319 assert!(!app.should_open_selected_issue_external_ref());
21320
21321 app.analyzer.issues[0].external_ref = Some("mailto:test@example.com".into());
21322 assert!(!app.should_open_selected_issue_external_ref());
21323
21324 app.analyzer.issues[0].external_ref = Some("https://github.com/org/repo/issues/42".into());
21325 assert!(app.should_open_selected_issue_external_ref());
21326 }
21327
21328 #[test]
21329 fn main_detail_copy_shortcut_only_activates_for_detail_focus_with_http_ref() {
21330 let mut app = new_app(ViewMode::Main, 0);
21331 assert!(!app.should_copy_selected_issue_external_ref());
21332
21333 app.focus = FocusPane::Detail;
21334 assert!(!app.should_copy_selected_issue_external_ref());
21335
21336 app.analyzer.issues[0].external_ref = Some("mailto:test@example.com".into());
21337 assert!(!app.should_copy_selected_issue_external_ref());
21338
21339 app.analyzer.issues[0].external_ref = Some("https://github.com/org/repo/issues/42".into());
21340 assert!(app.should_copy_selected_issue_external_ref());
21341 }
21342
21343 #[test]
21344 fn graph_detail_copy_shortcut_only_activates_for_detail_focus_with_http_ref() {
21345 let mut app = new_app(ViewMode::Graph, 0);
21346 assert!(!app.should_copy_selected_issue_external_ref());
21347
21348 app.focus = FocusPane::Detail;
21349 assert!(!app.should_copy_selected_issue_external_ref());
21350
21351 for issue in &mut app.analyzer.issues {
21352 issue.external_ref = Some("mailto:test@example.com".into());
21353 }
21354 assert!(!app.should_copy_selected_issue_external_ref());
21355
21356 for issue in &mut app.analyzer.issues {
21357 issue.external_ref = Some("https://github.com/org/repo/issues/42".into());
21358 }
21359 assert!(app.should_copy_selected_issue_external_ref());
21360 }
21361
21362 #[test]
21363 fn board_detail_copy_shortcut_only_activates_for_detail_focus_with_http_ref() {
21364 let mut app = new_app(ViewMode::Board, 0);
21365 assert!(!app.should_copy_selected_issue_external_ref());
21366
21367 app.focus = FocusPane::Detail;
21368 assert!(!app.should_copy_selected_issue_external_ref());
21369
21370 app.analyzer.issues[0].external_ref = Some("mailto:test@example.com".into());
21371 assert!(!app.should_copy_selected_issue_external_ref());
21372
21373 app.analyzer.issues[0].external_ref = Some("https://github.com/org/repo/issues/42".into());
21374 assert!(app.should_copy_selected_issue_external_ref());
21375 }
21376
21377 #[test]
21378 fn insights_detail_open_shortcut_only_activates_for_detail_focus_with_http_ref() {
21379 let mut app = new_app(ViewMode::Insights, 0);
21380 assert!(!app.should_open_selected_issue_external_ref());
21381
21382 app.focus = FocusPane::Detail;
21383 assert!(!app.should_open_selected_issue_external_ref());
21384
21385 for issue in &mut app.analyzer.issues {
21386 issue.external_ref = Some("mailto:test@example.com".into());
21387 }
21388 assert!(!app.should_open_selected_issue_external_ref());
21389
21390 for issue in &mut app.analyzer.issues {
21391 issue.external_ref = Some("https://github.com/org/repo/issues/42".into());
21392 }
21393 assert!(app.should_open_selected_issue_external_ref());
21394 }
21395
21396 #[test]
21397 fn insights_detail_copy_shortcut_only_activates_for_detail_focus_with_http_ref() {
21398 let mut app = new_app(ViewMode::Insights, 0);
21399 assert!(!app.should_copy_selected_issue_external_ref());
21400
21401 app.focus = FocusPane::Detail;
21402 assert!(!app.should_copy_selected_issue_external_ref());
21403
21404 for issue in &mut app.analyzer.issues {
21405 issue.external_ref = Some("mailto:test@example.com".into());
21406 }
21407 assert!(!app.should_copy_selected_issue_external_ref());
21408
21409 for issue in &mut app.analyzer.issues {
21410 issue.external_ref = Some("https://github.com/org/repo/issues/42".into());
21411 }
21412 assert!(app.should_copy_selected_issue_external_ref());
21413 }
21414
21415 #[test]
21416 fn main_mode_o_keeps_open_filter_shortcut_without_external_ref() {
21417 let mut app = new_app(ViewMode::Main, 0);
21418 app.focus = FocusPane::Detail;
21419 app.update(key(KeyCode::Char('o')));
21420 assert_eq!(app.list_filter, ListFilter::Open);
21421 }
21422
21423 #[test]
21424 fn main_mode_y_without_external_ref_does_not_set_status_message() {
21425 let mut app = new_app(ViewMode::Main, 0);
21426 app.focus = FocusPane::Detail;
21427 app.update(key(KeyCode::Char('y')));
21428 assert!(app.status_msg.is_empty());
21429 }
21430
21431 #[test]
21432 fn main_footer_shows_external_ref_commands_when_detail_link_is_available() {
21433 let mut app = new_app(ViewMode::Main, 0);
21434 app.focus = FocusPane::Detail;
21435 app.analyzer.issues[0].external_ref = Some("https://github.com/org/repo/issues/42".into());
21436
21437 let rendered = render_app(&app, 120, 40);
21438 assert!(
21439 rendered.contains("o open link"),
21440 "expected open-link hint, got:\n{rendered}"
21441 );
21442 assert!(
21443 rendered.contains("y copy link"),
21444 "expected copy-link hint, got:\n{rendered}"
21445 );
21446 }
21447
21448 #[test]
21449 fn main_footer_hides_external_ref_commands_without_detail_link() {
21450 let rendered = render_frame(ViewMode::Main, 120, 40);
21451 assert!(
21452 !rendered.contains("o open link"),
21453 "unexpected open-link hint in:\n{rendered}"
21454 );
21455 assert!(
21456 !rendered.contains("y copy link"),
21457 "unexpected copy-link hint in:\n{rendered}"
21458 );
21459 }
21460
21461 #[test]
21462 fn graph_footer_shows_external_ref_commands_when_detail_link_is_available() {
21463 let mut app = new_app(ViewMode::Graph, 0);
21464 app.focus = FocusPane::Detail;
21465 for issue in &mut app.analyzer.issues {
21466 issue.external_ref = Some("https://github.com/org/repo/issues/42".into());
21467 }
21468
21469 let rendered = render_app(&app, 120, 40);
21470 assert!(
21471 rendered.contains("o open link"),
21472 "expected graph footer to advertise open-link hint, got:\n{rendered}"
21473 );
21474 assert!(
21475 rendered.contains("y copy link"),
21476 "expected graph footer to advertise copy-link hint, got:\n{rendered}"
21477 );
21478 }
21479
21480 #[test]
21481 fn board_detail_render_shows_external_ref_link_actions() {
21482 let mut app = new_app(ViewMode::Board, 0);
21483 app.focus = FocusPane::Detail;
21484 app.analyzer.issues[0].external_ref = Some("https://github.com/org/repo/issues/42".into());
21485
21486 let detail = app.board_detail_render_text();
21487 let urls = detail
21488 .lines()
21489 .iter()
21490 .flat_map(ftui::text::Line::spans)
21491 .filter_map(|span| span.link.as_deref())
21492 .collect::<Vec<_>>();
21493 assert!(
21494 urls.iter()
21495 .any(|url| *url == "https://github.com/org/repo/issues/42"),
21496 "expected board detail hyperlink span to be rendered, got {urls:?}"
21497 );
21498
21499 let rendered = detail.to_plain_text();
21500 assert!(
21501 rendered.contains("(o open, y copy)"),
21502 "expected board detail inline external-ref action hint, got:\n{rendered}"
21503 );
21504 }
21505
21506 #[test]
21507 fn board_footer_shows_external_ref_commands_when_detail_link_is_available() {
21508 let mut app = new_app(ViewMode::Board, 0);
21509 app.focus = FocusPane::Detail;
21510 app.analyzer.issues[0].external_ref = Some("https://github.com/org/repo/issues/42".into());
21511
21512 let rendered = render_app(&app, 240, 40);
21515 assert!(
21516 rendered.contains("o open link"),
21517 "expected board footer to advertise open-link hint, got:\n{rendered}"
21518 );
21519 assert!(
21520 rendered.contains("y copy link"),
21521 "expected board footer to advertise copy-link hint, got:\n{rendered}"
21522 );
21523 }
21524
21525 #[test]
21526 fn board_footer_shows_status_message_when_present() {
21527 let mut app = new_app(ViewMode::Board, 0);
21528 app.status_msg = "Copied external issue reference to clipboard".into();
21529
21530 let rendered = render_app(&app, 120, 40);
21531 assert!(
21532 rendered.contains("Copied external issue reference to clipboard"),
21533 "expected board footer to surface status message, got:\n{rendered}"
21534 );
21535 }
21536
21537 #[test]
21538 fn board_footer_advertises_focus_and_search_controls() {
21539 let app = new_app(ViewMode::Board, 0);
21540
21541 let rendered = render_app(&app, 240, 40);
21542 assert!(
21543 rendered.contains("Tab focus"),
21544 "expected board footer to advertise focus switching, got:\n{rendered}"
21545 );
21546 assert!(
21547 rendered.contains("/ search"),
21548 "expected board footer to advertise search, got:\n{rendered}"
21549 );
21550 }
21551
21552 #[test]
21553 fn insights_detail_render_shows_external_ref_link_actions() {
21554 let mut app = new_app(ViewMode::Insights, 0);
21555 app.focus = FocusPane::Detail;
21556 for issue in &mut app.analyzer.issues {
21557 issue.external_ref = Some("https://github.com/org/repo/issues/42".into());
21558 }
21559
21560 let detail = app.insights_detail_render_text();
21561 let urls = detail
21562 .lines()
21563 .iter()
21564 .flat_map(ftui::text::Line::spans)
21565 .filter_map(|span| span.link.as_deref())
21566 .collect::<Vec<_>>();
21567 assert!(
21568 urls.iter()
21569 .any(|url| *url == "https://github.com/org/repo/issues/42"),
21570 "expected insights detail hyperlink span to be rendered, got {urls:?}"
21571 );
21572
21573 let rendered = detail.to_plain_text();
21574 assert!(
21575 rendered.contains("(o open, y copy)"),
21576 "expected insights detail inline external-ref action hint, got:\n{rendered}"
21577 );
21578 }
21579
21580 #[test]
21581 fn insights_footer_shows_external_ref_commands_when_detail_link_is_available() {
21582 let mut app = new_app(ViewMode::Insights, 0);
21583 app.focus = FocusPane::Detail;
21584 for issue in &mut app.analyzer.issues {
21585 issue.external_ref = Some("https://github.com/org/repo/issues/42".into());
21586 }
21587
21588 let rendered = render_app(&app, 120, 40);
21589 assert!(
21590 rendered.contains("o open link"),
21591 "expected insights footer to advertise open-link hint, got:\n{rendered}"
21592 );
21593 assert!(
21594 rendered.contains("y copy link"),
21595 "expected insights footer to advertise copy-link hint, got:\n{rendered}"
21596 );
21597 }
21598
21599 #[test]
21600 fn insights_footer_advertises_focus_and_search_controls() {
21601 let app = new_app(ViewMode::Insights, 0);
21602
21603 let rendered = render_app(&app, 120, 40);
21604 assert!(
21605 rendered.contains("Tab focus"),
21606 "expected insights footer to advertise focus switching, got:\n{rendered}"
21607 );
21608 assert!(
21609 rendered.contains("/ search"),
21610 "expected insights footer to advertise search, got:\n{rendered}"
21611 );
21612 }
21613
21614 #[test]
21615 fn insights_footer_shows_status_message_when_present() {
21616 let mut app = new_app(ViewMode::Insights, 0);
21617 app.status_msg = "Copied external issue reference to clipboard".into();
21618
21619 let rendered = render_app(&app, 120, 40);
21620 assert!(
21621 rendered.contains("Copied external issue reference to clipboard"),
21622 "expected insights footer to surface status message, got:\n{rendered}"
21623 );
21624 }
21625
21626 #[test]
21627 fn graph_footer_hides_external_ref_commands_without_detail_link() {
21628 let rendered = render_frame(ViewMode::Graph, 120, 40);
21629 assert!(
21630 !rendered.contains("o open link"),
21631 "unexpected graph open-link hint in:\n{rendered}"
21632 );
21633 assert!(
21634 !rendered.contains("y copy link"),
21635 "unexpected graph copy-link hint in:\n{rendered}"
21636 );
21637 }
21638
21639 #[test]
21640 fn graph_footer_shows_status_message_when_present() {
21641 let mut app = new_app(ViewMode::Graph, 0);
21642 app.status_msg = "Copied external issue reference to clipboard".into();
21643
21644 let rendered = render_app(&app, 120, 40);
21645 assert!(
21646 rendered.contains("Copied external issue reference to clipboard"),
21647 "expected graph footer to surface status message, got:\n{rendered}"
21648 );
21649 }
21650
21651 #[test]
21652 fn graph_footer_keeps_open_details_wording() {
21653 let rendered = render_frame(ViewMode::Graph, 120, 40);
21654 assert!(
21655 rendered.contains("Enter open details"),
21656 "expected graph footer to describe Enter accurately, got:\n{rendered}"
21657 );
21658 }
21659
21660 #[test]
21661 fn graph_footer_keeps_link_actions_visible_on_narrow_detail_layout() {
21662 let mut app = new_app(ViewMode::Graph, 0);
21663 app.focus = FocusPane::Detail;
21664 for issue in &mut app.analyzer.issues {
21665 issue.external_ref = Some("https://github.com/org/repo/issues/42".into());
21666 }
21667
21668 let rendered = render_app(&app, 80, 40);
21669 assert!(
21670 rendered.contains("o open link"),
21671 "expected narrow graph footer to keep open-link hint visible, got:\n{rendered}"
21672 );
21673 }
21676
21677 #[test]
21678 fn graph_footer_shows_scroll_hint_when_detail_focused() {
21679 let mut app = new_app(ViewMode::Graph, 0);
21680 app.focus = FocusPane::Detail;
21681
21682 let rendered = render_app(&app, 120, 40);
21683 assert!(
21684 rendered.contains("^j/k scroll"),
21685 "expected graph footer to advertise detail scrolling, got:\n{rendered}"
21686 );
21687 }
21688
21689 #[test]
21690 fn history_legacy_lifecycle_lines_match_go_shape() {
21691 let now = Utc::now();
21692 let history = HistoryBeadCompat {
21693 bead_id: "A".to_string(),
21694 title: "Root".to_string(),
21695 status: "open".to_string(),
21696 events: vec![
21697 HistoryEventCompat {
21698 bead_id: "A".to_string(),
21699 event_type: "created".to_string(),
21700 timestamp: (now - chrono::Duration::hours(3)).to_rfc3339(),
21701 commit_sha: String::new(),
21702 commit_message: String::new(),
21703 author: "Alice".to_string(),
21704 author_email: "alice@example.com".to_string(),
21705 },
21706 HistoryEventCompat {
21707 bead_id: "A".to_string(),
21708 event_type: "claimed".to_string(),
21709 timestamp: (now - chrono::Duration::hours(2)).to_rfc3339(),
21710 commit_sha: String::new(),
21711 commit_message: String::new(),
21712 author: "Bob Builder".to_string(),
21713 author_email: "bob@example.com".to_string(),
21714 },
21715 HistoryEventCompat {
21716 bead_id: "A".to_string(),
21717 event_type: "closed".to_string(),
21718 timestamp: (now - chrono::Duration::minutes(30)).to_rfc3339(),
21719 commit_sha: String::new(),
21720 commit_message: String::new(),
21721 author: "Carol".to_string(),
21722 author_email: "carol@example.com".to_string(),
21723 },
21724 ],
21725 milestones: HistoryMilestonesCompat::default(),
21726 commits: None,
21727 cycle_time: None,
21728 last_author: String::new(),
21729 };
21730
21731 let lines = history_legacy_lifecycle_lines(&history, 5);
21732 let text = lines.join("\n");
21733 assert!(text.contains("LIFECYCLE (3)"));
21734 assert!(text.contains("✓"));
21735 assert!(text.contains("👤"));
21736 assert!(text.contains("🆕"));
21737 assert!(text.contains("CA"));
21738 assert!(text.contains("BB"));
21739 assert!(text.contains("AL"));
21740 }
21741
21742 #[test]
21743 fn history_legacy_lifecycle_lines_show_overflow_summary() {
21744 let now = Utc::now();
21745 let events = (0..5)
21746 .map(|idx| HistoryEventCompat {
21747 bead_id: "A".to_string(),
21748 event_type: if idx == 0 {
21749 "created".to_string()
21750 } else {
21751 "updated".to_string()
21752 },
21753 timestamp: (now - chrono::Duration::hours(i64::from(5 - idx))).to_rfc3339(),
21754 commit_sha: String::new(),
21755 commit_message: String::new(),
21756 author: format!("Agent {idx}"),
21757 author_email: format!("agent{idx}@example.com"),
21758 })
21759 .collect::<Vec<_>>();
21760 let history = HistoryBeadCompat {
21761 bead_id: "A".to_string(),
21762 title: "Root".to_string(),
21763 status: "open".to_string(),
21764 events,
21765 milestones: HistoryMilestonesCompat::default(),
21766 commits: None,
21767 cycle_time: None,
21768 last_author: String::new(),
21769 };
21770
21771 let lines = history_legacy_lifecycle_lines(&history, 5);
21772 let text = lines.join("\n");
21773 assert_eq!(lines.len(), 5);
21774 assert!(text.contains("LIFECYCLE (5)"));
21775 assert!(text.contains("+2 more"));
21776 }
21777
21778 #[test]
21779 fn history_detail_prefers_legacy_lifecycle_summary_when_git_events_exist() {
21780 let mut app = history_app_with_git_cache(HistoryViewMode::Bead, 0);
21781 let now = Utc::now();
21782 {
21783 let history = app
21784 .history_git_cache
21785 .as_mut()
21786 .and_then(|cache| cache.histories.get_mut("A"))
21787 .expect("history A present");
21788 history.events = vec![
21789 HistoryEventCompat {
21790 bead_id: "A".to_string(),
21791 event_type: "created".to_string(),
21792 timestamp: (now - chrono::Duration::hours(3)).to_rfc3339(),
21793 commit_sha: String::new(),
21794 commit_message: String::new(),
21795 author: "Alice".to_string(),
21796 author_email: "alice@example.com".to_string(),
21797 },
21798 HistoryEventCompat {
21799 bead_id: "A".to_string(),
21800 event_type: "claimed".to_string(),
21801 timestamp: (now - chrono::Duration::hours(2)).to_rfc3339(),
21802 commit_sha: String::new(),
21803 commit_message: String::new(),
21804 author: "Bob Builder".to_string(),
21805 author_email: "bob@example.com".to_string(),
21806 },
21807 HistoryEventCompat {
21808 bead_id: "A".to_string(),
21809 event_type: "closed".to_string(),
21810 timestamp: (now - chrono::Duration::minutes(30)).to_rfc3339(),
21811 commit_sha: String::new(),
21812 commit_message: String::new(),
21813 author: "Carol".to_string(),
21814 author_email: "carol@example.com".to_string(),
21815 },
21816 ];
21817 }
21818
21819 let text = app.history_detail_text();
21820 assert!(text.contains("LIFECYCLE (3)"));
21821 assert!(text.contains("🆕"));
21822 assert!(text.contains("👤"));
21823 assert!(text.contains("✓"));
21824 assert!(text.contains("CA"));
21825 assert!(!text.contains(" │ created"));
21826 }
21827
21828 #[test]
21829 fn mouse_scroll_down_moves_selection() {
21830 let mut app = new_app(ViewMode::Main, 0);
21831 assert_eq!(app.selected, 0);
21832 app.handle_mouse(MouseEvent::new(MouseEventKind::ScrollDown, 0, 0));
21833 assert_eq!(app.selected, 1);
21834 app.handle_mouse(MouseEvent::new(MouseEventKind::ScrollDown, 0, 0));
21835 assert_eq!(app.selected, 2);
21836 }
21837
21838 #[test]
21839 fn mouse_scroll_up_moves_selection() {
21840 let mut app = new_app(ViewMode::Main, 2);
21841 assert_eq!(app.selected, 2);
21842 app.handle_mouse(MouseEvent::new(MouseEventKind::ScrollUp, 0, 0));
21843 assert_eq!(app.selected, 1);
21844 app.handle_mouse(MouseEvent::new(MouseEventKind::ScrollUp, 0, 0));
21845 assert_eq!(app.selected, 0);
21846 }
21847
21848 #[test]
21849 fn mouse_scroll_works_in_board_mode() {
21850 let mut app = new_app(ViewMode::Board, 0);
21851 app.handle_mouse(MouseEvent::new(MouseEventKind::ScrollDown, 0, 0));
21852 assert!(app.selected <= 1);
21854 }
21855
21856 #[test]
21857 fn mouse_other_events_are_noop() {
21858 let mut app = new_app(ViewMode::Main, 0);
21859 app.handle_mouse(MouseEvent::new(MouseEventKind::Moved, 0, 0));
21860 assert_eq!(app.selected, 0);
21861 }
21862
21863 #[test]
21864 fn mouse_left_click_opens_main_detail_external_link() {
21865 let mut app = new_app(ViewMode::Main, 0);
21866 app.focus = FocusPane::Detail;
21867 app.analyzer.issues[0].external_ref = Some("https://github.com/org/repo/issues/42".into());
21868 let (x, y) = detail_link_click_point(&app, 120, 40).expect("detail link point");
21869
21870 app.update(mouse(MouseEventKind::Down(MouseButton::Left), x, y));
21871
21872 assert!(
21873 !app.status_msg.is_empty(),
21874 "expected click to trigger open-link status"
21875 );
21876 assert_ne!(app.status_msg, "No external issue reference");
21877 }
21878
21879 #[test]
21880 fn mouse_left_click_opens_board_detail_external_link() {
21881 let mut app = new_app(ViewMode::Board, 0);
21882 app.focus = FocusPane::Detail;
21883 app.analyzer.issues[0].external_ref = Some("https://github.com/org/repo/issues/42".into());
21884 let (x, y) = detail_link_click_point(&app, 120, 40).expect("detail link point");
21885
21886 app.update(mouse(MouseEventKind::Down(MouseButton::Left), x, y));
21887
21888 assert!(
21889 !app.status_msg.is_empty(),
21890 "expected click to trigger open-link status"
21891 );
21892 assert_ne!(app.status_msg, "No external issue reference");
21893 }
21894
21895 #[test]
21896 fn mouse_click_outside_main_detail_link_row_is_noop() {
21897 let mut app = new_app(ViewMode::Main, 0);
21898 app.focus = FocusPane::Detail;
21899 app.analyzer.issues[0].external_ref = Some("https://github.com/org/repo/issues/42".into());
21900 let (x, y) = detail_non_link_click_point(&app, 120, 40).expect("non-link detail point");
21901
21902 app.update(mouse(MouseEventKind::Down(MouseButton::Left), x, y));
21903
21904 assert!(
21905 app.status_msg.is_empty(),
21906 "expected non-link click to stay inert, got {:?}",
21907 app.status_msg
21908 );
21909 }
21910
21911 #[test]
21912 fn mouse_left_click_on_header_mode_tab_switches_mode() {
21913 let mut app = new_app(ViewMode::Main, 0);
21914 let (x, y) =
21915 header_tab_click_point(&app, 120, 24, ViewMode::Graph).expect("graph header tab point");
21916
21917 app.update(mouse(MouseEventKind::Down(MouseButton::Left), x, y));
21918
21919 assert_eq!(app.mode, ViewMode::Graph);
21920 assert_eq!(app.focus, FocusPane::List);
21921 assert_eq!(app.status_msg, "Switched to Graph");
21922 }
21923
21924 #[test]
21925 fn current_detail_link_row_area_tracks_main_detail_scroll_offset() {
21926 let mut app = new_app(ViewMode::Main, 0);
21927 app.focus = FocusPane::Detail;
21928 app.analyzer.issues[0].external_ref = Some("https://github.com/org/repo/issues/42".into());
21929
21930 let _ = render_app(&app, 120, 40);
21931 let initial = app
21932 .current_detail_link_row_area()
21933 .expect("initial main detail link row area");
21934
21935 app.detail_scroll_offset = 1;
21936 let _ = render_app(&app, 120, 40);
21937 let scrolled = app
21938 .current_detail_link_row_area()
21939 .expect("scrolled main detail link row area");
21940
21941 assert_eq!(scrolled.y, initial.y.saturating_sub(1));
21942 assert_eq!(scrolled.x, initial.x);
21943 assert_eq!(scrolled.width, initial.width);
21944 }
21945
21946 #[test]
21947 fn current_detail_link_row_area_matches_board_hyperlink_row() {
21948 let mut app = new_app(ViewMode::Board, 0);
21949 app.focus = FocusPane::Detail;
21950 app.analyzer.issues[0].external_ref = Some("https://github.com/org/repo/issues/42".into());
21951
21952 let _ = render_app(&app, 120, 40);
21953 let link_area = app
21954 .current_detail_link_row_area()
21955 .expect("board detail link row area");
21956 let detail_area = cached_detail_content_area();
21957 let detail = app.board_detail_render_text();
21958 let expected_row = detail
21959 .lines()
21960 .iter()
21961 .position(|line| {
21962 ftui::text::Line::spans(line)
21963 .iter()
21964 .any(|span| span.link.is_some())
21965 })
21966 .expect("board detail hyperlink row");
21967
21968 assert_eq!(
21969 link_area.y,
21970 detail_area
21971 .y
21972 .saturating_add(saturating_scroll_offset(expected_row)),
21973 );
21974 }
21975
21976 #[test]
21977 fn current_detail_link_row_area_tracks_board_detail_scroll_offset() {
21978 let mut app = new_app(ViewMode::Board, 0);
21979 app.focus = FocusPane::Detail;
21980 app.analyzer.issues[0].external_ref = Some("https://github.com/org/repo/issues/42".into());
21981
21982 let _ = render_app(&app, 120, 40);
21983 let initial = app
21984 .current_detail_link_row_area()
21985 .expect("initial board detail link row area");
21986
21987 app.board_detail_scroll_offset = 1;
21988 let _ = render_app(&app, 120, 40);
21989 let scrolled = app
21990 .current_detail_link_row_area()
21991 .expect("scrolled board detail link row area");
21992
21993 assert_eq!(scrolled.y, initial.y.saturating_sub(1));
21994 assert_eq!(scrolled.height, initial.height);
21995 assert_eq!(scrolled.width, initial.width);
21996 }
21997
21998 #[test]
21999 fn current_detail_link_row_area_matches_graph_hyperlink_row() {
22000 let mut app = new_app(ViewMode::Graph, 0);
22001 app.focus = FocusPane::Detail;
22002 for issue in &mut app.analyzer.issues {
22003 issue.external_ref = Some("https://github.com/org/repo/issues/42".into());
22004 }
22005
22006 let _ = render_app(&app, 120, 40);
22007 let link_area = app
22008 .current_detail_link_row_area()
22009 .expect("graph detail link row area");
22010 let detail_area = cached_detail_content_area();
22011 let detail = app.graph_detail_render_text();
22012 let expected_row = detail
22013 .lines()
22014 .iter()
22015 .position(|line| {
22016 ftui::text::Line::spans(line)
22017 .iter()
22018 .any(|span| span.link.is_some())
22019 })
22020 .expect("graph detail hyperlink row");
22021
22022 assert_eq!(
22023 link_area.y,
22024 detail_area
22025 .y
22026 .saturating_add(saturating_scroll_offset(expected_row)),
22027 );
22028 }
22029
22030 #[test]
22031 fn current_detail_link_row_area_tracks_graph_detail_scroll_offset() {
22032 let mut app = new_app(ViewMode::Graph, 0);
22033 app.focus = FocusPane::Detail;
22034 for issue in &mut app.analyzer.issues {
22035 issue.external_ref = Some("https://github.com/org/repo/issues/42".into());
22036 }
22037
22038 let _ = render_app(&app, 120, 40);
22039 let initial = app
22040 .current_detail_link_row_area()
22041 .expect("initial graph detail link row area");
22042
22043 app.detail_scroll_offset = 1;
22044 let _ = render_app(&app, 120, 40);
22045 let scrolled = app
22046 .current_detail_link_row_area()
22047 .expect("scrolled graph detail link row area");
22048
22049 assert_eq!(scrolled.y, initial.y.saturating_sub(1));
22050 assert_eq!(scrolled.x, initial.x);
22051 assert_eq!(scrolled.width, initial.width);
22052 }
22053
22054 #[test]
22055 fn current_detail_link_row_area_matches_insights_hyperlink_row() {
22056 let mut app = new_app(ViewMode::Insights, 0);
22057 app.focus = FocusPane::Detail;
22058 for issue in &mut app.analyzer.issues {
22059 issue.external_ref = Some("https://github.com/org/repo/issues/42".into());
22060 }
22061
22062 let _ = render_app(&app, 120, 40);
22063 let link_area = app
22064 .current_detail_link_row_area()
22065 .expect("insights detail link row area");
22066 let detail_area = cached_detail_content_area();
22067 let detail = app.insights_detail_render_text();
22068 let expected_row = detail
22069 .lines()
22070 .iter()
22071 .position(|line| {
22072 ftui::text::Line::spans(line)
22073 .iter()
22074 .any(|span| span.link.is_some())
22075 })
22076 .expect("insights detail hyperlink row");
22077
22078 assert_eq!(
22079 link_area.y,
22080 detail_area
22081 .y
22082 .saturating_add(saturating_scroll_offset(expected_row)),
22083 );
22084 }
22085
22086 #[test]
22087 fn current_detail_link_row_area_tracks_insights_detail_scroll_offset() {
22088 let mut app = new_app(ViewMode::Insights, 0);
22089 app.focus = FocusPane::Detail;
22090 for issue in &mut app.analyzer.issues {
22091 issue.external_ref = Some("https://github.com/org/repo/issues/42".into());
22092 }
22093
22094 let _ = render_app(&app, 120, 40);
22095 let initial = app
22096 .current_detail_link_row_area()
22097 .expect("initial insights detail link row area");
22098
22099 app.detail_scroll_offset = 1;
22100 let _ = render_app(&app, 120, 40);
22101 let scrolled = app
22102 .current_detail_link_row_area()
22103 .expect("scrolled insights detail link row area");
22104
22105 assert_eq!(scrolled.y, initial.y.saturating_sub(1));
22106 assert_eq!(scrolled.height, initial.height);
22107 assert_eq!(scrolled.width, initial.width);
22108 }
22109
22110 #[test]
22111 fn mouse_right_click_copies_graph_detail_external_link() {
22112 let mut app = new_app(ViewMode::Graph, 0);
22113 app.focus = FocusPane::Detail;
22114 for issue in &mut app.analyzer.issues {
22115 issue.external_ref = Some("https://github.com/org/repo/issues/42".into());
22116 }
22117 let (x, y) = detail_link_click_point(&app, 120, 40).expect("detail link point");
22118
22119 app.update(mouse(MouseEventKind::Down(MouseButton::Right), x, y));
22120
22121 assert!(
22122 !app.status_msg.is_empty(),
22123 "expected click to trigger copy-link status"
22124 );
22125 assert_ne!(app.status_msg, "No external issue reference");
22126 }
22127
22128 #[test]
22129 fn mouse_left_click_opens_insights_detail_external_link() {
22130 let mut app = new_app(ViewMode::Insights, 0);
22131 app.focus = FocusPane::Detail;
22132 for issue in &mut app.analyzer.issues {
22133 issue.external_ref = Some("https://github.com/org/repo/issues/42".into());
22134 }
22135 let (x, y) = detail_link_click_point(&app, 120, 40).expect("detail link point");
22136
22137 app.update(mouse(MouseEventKind::Down(MouseButton::Left), x, y));
22138
22139 assert!(
22140 !app.status_msg.is_empty(),
22141 "expected click to trigger open-link status"
22142 );
22143 assert_ne!(app.status_msg, "No external issue reference");
22144 }
22145
22146 #[test]
22147 fn mouse_left_click_opens_history_commit_link() {
22148 let mut app = history_app_with_git_cache(HistoryViewMode::Bead, 0);
22149 app.mode = ViewMode::History;
22150 app.focus = FocusPane::Detail;
22151 let temp = tempfile::tempdir().expect("temp git dir");
22152 std::process::Command::new("git")
22153 .args(["init"])
22154 .current_dir(temp.path())
22155 .output()
22156 .expect("init git repo");
22157 std::process::Command::new("git")
22158 .args(["remote", "add", "origin", "https://github.com/org/repo.git"])
22159 .current_dir(temp.path())
22160 .output()
22161 .expect("add git remote");
22162 app.repo_root = Some(temp.path().to_path_buf());
22163 let (x, y) = detail_link_click_point(&app, 120, 40).expect("history link point");
22164
22165 app.update(mouse(MouseEventKind::Down(MouseButton::Left), x, y));
22166
22167 assert!(
22168 !app.history_status_msg.is_empty(),
22169 "expected click to trigger history open status"
22170 );
22171 assert_ne!(app.history_status_msg, "No commit selected");
22172 }
22173
22174 #[test]
22175 fn mouse_right_click_copies_history_commit_link() {
22176 let mut app = history_app_with_git_cache(HistoryViewMode::Bead, 0);
22177 app.mode = ViewMode::History;
22178 app.focus = FocusPane::Detail;
22179 let temp = tempfile::tempdir().expect("temp git dir");
22180 std::process::Command::new("git")
22181 .args(["init"])
22182 .current_dir(temp.path())
22183 .output()
22184 .expect("init git repo");
22185 std::process::Command::new("git")
22186 .args(["remote", "add", "origin", "https://github.com/org/repo.git"])
22187 .current_dir(temp.path())
22188 .output()
22189 .expect("add git remote");
22190 app.repo_root = Some(temp.path().to_path_buf());
22191 let (x, y) = detail_link_click_point(&app, 120, 40).expect("history link point");
22192
22193 app.update(mouse(MouseEventKind::Down(MouseButton::Right), x, y));
22194
22195 assert!(
22196 !app.history_status_msg.is_empty(),
22197 "expected right-click to trigger history copy status"
22198 );
22199 assert_ne!(app.history_status_msg, "No commit selected");
22200 }
22201
22202 #[test]
22203 fn mouse_click_outside_history_detail_link_row_is_noop() {
22204 let mut app = history_app_with_git_cache(HistoryViewMode::Bead, 0);
22205 app.mode = ViewMode::History;
22206 app.focus = FocusPane::Detail;
22207 let temp = tempfile::tempdir().expect("temp git dir");
22208 std::process::Command::new("git")
22209 .args(["init"])
22210 .current_dir(temp.path())
22211 .output()
22212 .expect("init git repo");
22213 std::process::Command::new("git")
22214 .args(["remote", "add", "origin", "https://github.com/org/repo.git"])
22215 .current_dir(temp.path())
22216 .output()
22217 .expect("add git remote");
22218 app.repo_root = Some(temp.path().to_path_buf());
22219 let (x, y) = detail_non_link_click_point(&app, 120, 40).expect("non-link history point");
22220
22221 app.update(mouse(MouseEventKind::Down(MouseButton::Left), x, y));
22222
22223 assert!(
22224 app.history_status_msg.is_empty(),
22225 "expected non-link click to stay inert, got {:?}",
22226 app.history_status_msg
22227 );
22228 }
22229
22230 #[test]
22231 fn tree_view_toggle() {
22232 let mut app = new_app(ViewMode::Main, 0);
22233 assert!(matches!(app.mode, ViewMode::Main));
22234
22235 app.handle_key(KeyCode::Char('T'), Modifiers::NONE);
22237 assert!(matches!(app.mode, ViewMode::Tree));
22238 assert!(!app.tree_flat_nodes.is_empty(), "tree should build nodes");
22239
22240 app.handle_key(KeyCode::Char('T'), Modifiers::NONE);
22242 assert!(matches!(app.mode, ViewMode::Main));
22243 }
22244
22245 #[test]
22246 fn tree_view_navigation() {
22247 let mut app = new_app(ViewMode::Main, 0);
22248 app.handle_key(KeyCode::Char('T'), Modifiers::NONE);
22249 assert!(matches!(app.mode, ViewMode::Tree));
22250 assert_eq!(app.tree_cursor, 0);
22251
22252 if app.tree_flat_nodes.len() > 1 {
22253 app.handle_key(KeyCode::Char('j'), Modifiers::NONE);
22254 assert_eq!(app.tree_cursor, 1);
22255 app.handle_key(KeyCode::Char('k'), Modifiers::NONE);
22256 assert_eq!(app.tree_cursor, 0);
22257 }
22258 }
22259
22260 #[test]
22261 fn tree_view_renders_list_and_detail() {
22262 let mut app = new_app(ViewMode::Main, 0);
22263 app.handle_key(KeyCode::Char('T'), Modifiers::NONE);
22264
22265 let list = app.tree_list_text();
22266 assert!(
22267 list.contains("Dependency tree"),
22268 "list should show tree header, got: {list}"
22269 );
22270
22271 let detail = app.tree_detail_text();
22272 assert!(
22273 detail.contains("ID:"),
22274 "detail should show issue ID, got: {detail}"
22275 );
22276 }
22277
22278 #[test]
22279 fn label_dashboard_toggle() {
22280 let mut app = new_app(ViewMode::Main, 0);
22281 assert!(matches!(app.mode, ViewMode::Main));
22282
22283 app.handle_key(KeyCode::Char('['), Modifiers::NONE);
22285 assert!(matches!(app.mode, ViewMode::LabelDashboard));
22286 assert!(app.label_dashboard.is_some());
22287
22288 app.handle_key(KeyCode::Char('['), Modifiers::NONE);
22290 assert!(matches!(app.mode, ViewMode::Main));
22291 }
22292
22293 #[test]
22294 fn label_dashboard_navigation() {
22295 let mut app = new_app(ViewMode::Main, 0);
22296 app.handle_key(KeyCode::Char('['), Modifiers::NONE);
22297 assert_eq!(app.label_dashboard_cursor, 0);
22298
22299 let count = app.label_dashboard.as_ref().map_or(0, |r| r.labels.len());
22300 if count > 1 {
22301 app.handle_key(KeyCode::Char('j'), Modifiers::NONE);
22302 assert_eq!(app.label_dashboard_cursor, 1);
22303 app.handle_key(KeyCode::Char('k'), Modifiers::NONE);
22304 assert_eq!(app.label_dashboard_cursor, 0);
22305 }
22306 }
22307
22308 #[test]
22309 fn label_dashboard_renders_list_and_detail() {
22310 let mut app = new_app(ViewMode::Main, 0);
22311 app.handle_key(KeyCode::Char('['), Modifiers::NONE);
22312
22313 let list = app.label_dashboard_list_text();
22314 assert!(
22315 list.contains("Label health") || list.contains("no labels"),
22316 "list should show header, got: {list}"
22317 );
22318
22319 let detail = app.label_dashboard_detail_text();
22320 if app
22322 .label_dashboard
22323 .as_ref()
22324 .is_some_and(|r| !r.labels.is_empty())
22325 {
22326 assert!(
22327 detail.contains("Label:"),
22328 "detail should show label name, got: {detail}"
22329 );
22330 }
22331 }
22332
22333 #[test]
22334 fn tree_view_expand_collapse() {
22335 let mut app = new_app(ViewMode::Main, 0);
22336 app.handle_key(KeyCode::Char('T'), Modifiers::NONE);
22337
22338 let has_children_node = app.tree_flat_nodes.iter().position(|n| n.has_children);
22340 if let Some(idx) = has_children_node {
22341 app.tree_cursor = idx;
22342 let initial_count = app.tree_flat_nodes.len();
22343
22344 app.handle_key(KeyCode::Enter, Modifiers::NONE);
22346 assert!(
22347 app.tree_flat_nodes.len() < initial_count,
22348 "collapsing should reduce node count"
22349 );
22350
22351 app.handle_key(KeyCode::Enter, Modifiers::NONE);
22353 assert_eq!(
22354 app.tree_flat_nodes.len(),
22355 initial_count,
22356 "expanding should restore node count"
22357 );
22358 }
22359 }
22360
22361 #[test]
22362 fn flow_matrix_toggle() {
22363 let mut app = new_app(ViewMode::Main, 0);
22364 assert!(matches!(app.mode, ViewMode::Main));
22365
22366 app.handle_key(KeyCode::Char(']'), Modifiers::NONE);
22368 assert!(matches!(app.mode, ViewMode::FlowMatrix));
22369 assert!(app.flow_matrix.is_some());
22370
22371 app.handle_key(KeyCode::Char(']'), Modifiers::NONE);
22373 assert!(matches!(app.mode, ViewMode::Main));
22374 }
22375
22376 #[test]
22377 fn flow_matrix_navigation() {
22378 let mut app = new_app(ViewMode::Main, 0);
22379 app.handle_key(KeyCode::Char(']'), Modifiers::NONE);
22380 assert_eq!(app.flow_matrix_row_cursor, 0);
22381 assert_eq!(app.flow_matrix_col_cursor, 0);
22382
22383 let count = app.flow_matrix.as_ref().map_or(0, |f| f.labels.len());
22384 if count > 1 {
22385 app.handle_key(KeyCode::Char('j'), Modifiers::NONE);
22387 assert_eq!(app.flow_matrix_row_cursor, 1);
22388 app.handle_key(KeyCode::Char('k'), Modifiers::NONE);
22389 assert_eq!(app.flow_matrix_row_cursor, 0);
22390
22391 app.handle_key(KeyCode::Char('l'), Modifiers::NONE);
22393 assert_eq!(app.flow_matrix_col_cursor, 1);
22394 app.handle_key(KeyCode::Char('h'), Modifiers::NONE);
22395 assert_eq!(app.flow_matrix_col_cursor, 0);
22396 }
22397 }
22398
22399 #[test]
22400 fn flow_matrix_renders_list_and_detail() {
22401 let mut app = new_app(ViewMode::Main, 0);
22402 app.handle_key(KeyCode::Char(']'), Modifiers::NONE);
22403
22404 let list = app.flow_matrix_list_text();
22405 assert!(
22406 list.contains("Cross-label flow") || list.contains("no labels"),
22407 "list should show header or empty, got: {list}"
22408 );
22409
22410 let detail = app.flow_matrix_detail_text();
22411 assert!(!detail.is_empty(), "detail should not be empty");
22413 }
22414
22415 #[test]
22416 fn flow_matrix_list_handles_wide_unicode_labels_without_overflow() {
22417 let mut app = new_app(ViewMode::FlowMatrix, 0);
22418 app.mode = ViewMode::FlowMatrix;
22419 app.flow_matrix = Some(CrossLabelFlow {
22420 labels: vec!["界面".to_string(), "🚀-launch".to_string()],
22421 flow_matrix: vec![vec![0, 3], vec![1, 0]],
22422 dependencies: Vec::new(),
22423 bottleneck_labels: vec!["界面".to_string()],
22424 total_cross_label_deps: 4,
22425 });
22426 app.flow_matrix_row_cursor = 0;
22427 app.flow_matrix_col_cursor = 1;
22428
22429 let list = app.flow_matrix_list_text();
22430 assert!(list.contains("界面"));
22431 assert!(list.contains("🚀-launch"));
22432 assert!(
22433 list.lines().all(|line| display_width(line) <= 80),
22434 "every flow-matrix line should remain width-safe: {list}"
22435 );
22436 }
22437
22438 #[test]
22439 fn remote_to_commit_url_ssh() {
22440 let url = super::remote_to_commit_url("git@github.com:owner/repo.git", "abc123");
22441 assert_eq!(
22442 url,
22443 Some("https://github.com/owner/repo/commit/abc123".into())
22444 );
22445 }
22446
22447 #[test]
22448 fn remote_to_commit_url_https() {
22449 let url = super::remote_to_commit_url("https://github.com/owner/repo.git", "def456");
22450 assert_eq!(
22451 url,
22452 Some("https://github.com/owner/repo/commit/def456".into())
22453 );
22454 }
22455
22456 #[test]
22457 fn remote_to_commit_url_no_git_suffix() {
22458 let url = super::remote_to_commit_url("https://github.com/owner/repo", "sha789");
22459 assert_eq!(
22460 url,
22461 Some("https://github.com/owner/repo/commit/sha789".into())
22462 );
22463 }
22464
22465 #[test]
22466 fn file_tree_node_flatten_visible() {
22467 let node = super::FileTreeNode {
22468 name: "src".into(),
22469 path: "src".into(),
22470 is_dir: true,
22471 change_count: 3,
22472 expanded: true,
22473 level: 0,
22474 children: vec![
22475 super::FileTreeNode {
22476 name: "main.rs".into(),
22477 path: "src/main.rs".into(),
22478 is_dir: false,
22479 change_count: 2,
22480 expanded: false,
22481 level: 1,
22482 children: vec![],
22483 },
22484 super::FileTreeNode {
22485 name: "lib.rs".into(),
22486 path: "src/lib.rs".into(),
22487 is_dir: false,
22488 change_count: 1,
22489 expanded: false,
22490 level: 1,
22491 children: vec![],
22492 },
22493 ],
22494 };
22495
22496 let flat = node.flatten_visible();
22497 assert_eq!(flat.len(), 3);
22498 assert_eq!(flat[0].name, "src");
22499 assert!(flat[0].is_dir);
22500 assert_eq!(flat[1].name, "main.rs");
22501 assert_eq!(flat[2].name, "lib.rs");
22502 }
22503
22504 #[test]
22507 fn modal_tutorial_dismisses_on_any_key() {
22508 let mut app = new_app(ViewMode::Main, 0);
22509 app.open_tutorial();
22510 assert!(app.modal_overlay.is_some());
22511 assert!(matches!(app.modal_overlay, Some(ModalOverlay::Tutorial)));
22512
22513 app.update(key(KeyCode::Char('x')));
22514 assert!(app.modal_overlay.is_none());
22515 }
22516
22517 #[test]
22518 fn ctrl_t_opens_and_dismisses_tutorial_modal() {
22519 let mut app = new_app(ViewMode::Main, 0);
22520
22521 app.update(key_ctrl(KeyCode::Char('t')));
22522 assert!(matches!(app.modal_overlay, Some(ModalOverlay::Tutorial)));
22523
22524 app.update(key(KeyCode::Char('x')));
22525 assert!(app.modal_overlay.is_none());
22526 }
22527
22528 #[test]
22529 fn modal_confirm_accepts_on_y_rejects_on_n() {
22530 let mut app = new_app(ViewMode::Main, 0);
22531 app.open_confirm_with_resume("Test", "Do you confirm?", None);
22532 assert!(app.modal_overlay.is_some());
22533 assert!(app.modal_confirm_result.is_none());
22534
22535 app.update(key(KeyCode::Char('y')));
22536 assert!(app.modal_overlay.is_none());
22537 assert_eq!(app.modal_confirm_result, Some(true));
22538
22539 app.open_confirm_with_resume("Test", "Another question?", None);
22540 app.update(key(KeyCode::Char('n')));
22541 assert!(app.modal_overlay.is_none());
22542 assert_eq!(app.modal_confirm_result, Some(false));
22543
22544 app.open_confirm_with_resume("Test", "Third question?", None);
22545 app.update(key(KeyCode::Escape));
22546 assert!(app.modal_overlay.is_none());
22547 assert_eq!(app.modal_confirm_result, Some(false));
22548 }
22549
22550 #[test]
22551 fn modal_confirm_ignores_unrelated_keys() {
22552 let mut app = new_app(ViewMode::Main, 0);
22553 app.open_confirm_with_resume("Test", "Do you confirm?", None);
22554
22555 app.update(key(KeyCode::Char('x')));
22556 assert!(app.modal_overlay.is_some());
22557 assert!(app.modal_confirm_result.is_none());
22558 }
22559
22560 #[test]
22561 fn modal_pages_wizard_step_navigation() {
22562 let mut app = new_app(ViewMode::Main, 0);
22563 app.open_pages_wizard();
22564
22565 match &app.modal_overlay {
22566 Some(ModalOverlay::PagesWizard(wiz)) => {
22567 assert_eq!(wiz.step, 0);
22568 assert_eq!(wiz.export_dir, "./bv-pages");
22569 }
22570 other => panic!("Expected PagesWizard, got {other:?}"),
22571 }
22572
22573 app.update(key(KeyCode::Char('x')));
22574 match &app.modal_overlay {
22575 Some(ModalOverlay::PagesWizard(wiz)) => {
22576 assert_eq!(wiz.export_dir, "./bv-pagesx");
22577 }
22578 _ => panic!("lost wizard"),
22579 }
22580
22581 app.update(key(KeyCode::Backspace));
22582 match &app.modal_overlay {
22583 Some(ModalOverlay::PagesWizard(wiz)) => {
22584 assert_eq!(wiz.export_dir, "./bv-pages");
22585 }
22586 _ => panic!("lost wizard"),
22587 }
22588
22589 app.update(key(KeyCode::Enter));
22590 match &app.modal_overlay {
22591 Some(ModalOverlay::PagesWizard(wiz)) => {
22592 assert_eq!(wiz.step, 1);
22593 assert_eq!(wiz.step_label(), "Page Title");
22594 }
22595 _ => panic!("lost wizard"),
22596 }
22597
22598 app.update(key(KeyCode::Enter));
22599 match &app.modal_overlay {
22600 Some(ModalOverlay::PagesWizard(wiz)) => {
22601 assert_eq!(wiz.step, 2);
22602 assert!(wiz.include_closed);
22603 assert!(wiz.include_history);
22604 }
22605 _ => panic!("lost wizard"),
22606 }
22607
22608 app.update(key(KeyCode::Char('c')));
22609 match &app.modal_overlay {
22610 Some(ModalOverlay::PagesWizard(wiz)) => assert!(!wiz.include_closed),
22611 _ => panic!("lost wizard"),
22612 }
22613 app.update(key(KeyCode::Char('h')));
22614 match &app.modal_overlay {
22615 Some(ModalOverlay::PagesWizard(wiz)) => assert!(!wiz.include_history),
22616 _ => panic!("lost wizard"),
22617 }
22618
22619 app.update(key(KeyCode::Enter));
22620 match &app.modal_overlay {
22621 Some(ModalOverlay::PagesWizard(wiz)) => {
22622 assert_eq!(wiz.step, 3);
22623 }
22624 _ => panic!("lost wizard"),
22625 }
22626
22627 app.update(key(KeyCode::Enter));
22628 assert!(matches!(
22629 app.modal_overlay,
22630 Some(ModalOverlay::Confirm { .. })
22631 ));
22632 assert!(app.modal_confirm_result.is_none());
22633
22634 app.update(key(KeyCode::Char('y')));
22635 assert!(app.modal_overlay.is_none());
22636 assert_eq!(app.modal_confirm_result, Some(true));
22637 }
22638
22639 #[test]
22640 fn uppercase_p_opens_pages_wizard_modal() {
22641 let mut app = new_app(ViewMode::Main, 0);
22642
22643 app.update(key(KeyCode::Char('P')));
22644
22645 assert!(matches!(
22646 app.modal_overlay,
22647 Some(ModalOverlay::PagesWizard(_))
22648 ));
22649 }
22650
22651 #[test]
22652 fn modal_pages_wizard_escape_cancels() {
22653 let mut app = new_app(ViewMode::Main, 0);
22654 app.open_pages_wizard();
22655 app.update(key(KeyCode::Enter));
22656 app.update(key(KeyCode::Escape));
22657 assert!(app.modal_overlay.is_none());
22658 }
22659
22660 #[test]
22661 fn modal_pages_wizard_confirm_cancel_restores_wizard() {
22662 let mut app = new_app(ViewMode::Main, 0);
22663 app.open_pages_wizard();
22664
22665 app.update(key(KeyCode::Enter));
22666 app.update(key(KeyCode::Enter));
22667 app.update(key(KeyCode::Enter));
22668 app.update(key(KeyCode::Enter));
22669
22670 assert!(matches!(
22671 app.modal_overlay,
22672 Some(ModalOverlay::Confirm { .. })
22673 ));
22674
22675 app.update(key(KeyCode::Escape));
22676 match &app.modal_overlay {
22677 Some(ModalOverlay::PagesWizard(wiz)) => {
22678 assert_eq!(wiz.step, 3);
22679 assert_eq!(wiz.export_dir, "./bv-pages");
22680 }
22681 other => panic!("expected PagesWizard to resume after cancel, got {other:?}"),
22682 }
22683 assert_eq!(app.modal_confirm_result, Some(false));
22684 }
22685
22686 #[test]
22687 fn modal_pages_wizard_backspace_goes_back() {
22688 let mut app = new_app(ViewMode::Main, 0);
22689 app.open_pages_wizard();
22690 app.update(key(KeyCode::Enter));
22691 app.update(key(KeyCode::Enter));
22692 match &app.modal_overlay {
22693 Some(ModalOverlay::PagesWizard(wiz)) => assert_eq!(wiz.step, 2),
22694 _ => panic!("lost wizard"),
22695 }
22696 app.update(key(KeyCode::Backspace));
22697 match &app.modal_overlay {
22698 Some(ModalOverlay::PagesWizard(wiz)) => assert_eq!(wiz.step, 1),
22699 _ => panic!("lost wizard"),
22700 }
22701 }
22702
22703 #[test]
22704 fn modal_state_transitions_help_to_quit_cycle() {
22705 let mut app = new_app(ViewMode::Main, 0);
22706
22707 app.update(key(KeyCode::Char('?')));
22708 assert!(app.show_help);
22709 assert!(app.modal_overlay.is_none());
22710
22711 app.update(key(KeyCode::Escape));
22712 assert!(!app.show_help);
22713
22714 app.update(key(KeyCode::Escape));
22715 assert!(app.show_quit_confirm);
22716
22717 app.update(key(KeyCode::Char('x')));
22718 assert!(!app.show_quit_confirm);
22719
22720 app.open_tutorial();
22721 assert!(app.modal_overlay.is_some());
22722 assert!(!app.show_help);
22723
22724 app.update(key(KeyCode::Enter));
22725 assert!(app.modal_overlay.is_none());
22726 }
22727
22728 #[test]
22729 fn modal_overlay_blocks_normal_key_handling() {
22730 let mut app = new_app(ViewMode::Main, 0);
22731 let initial_mode = app.mode;
22732
22733 app.open_confirm_with_resume("Test", "Question", None);
22734 app.update(key(KeyCode::Char('b')));
22735 assert_eq!(app.mode, initial_mode);
22736 assert!(app.modal_overlay.is_some());
22737
22738 app.update(key(KeyCode::Char('y')));
22739 assert!(app.modal_overlay.is_none());
22740 }
22741
22742 fn render_frame(mode: ViewMode, width: u16, height: u16) -> String {
22752 let app = new_app(mode, 0);
22753 render_app(&app, width, height)
22754 }
22755
22756 #[allow(dead_code)]
22759 fn redact_git_volatile(text: &str) -> String {
22760 let mut result = String::with_capacity(text.len());
22761
22762 for line in text.lines() {
22763 let mut redacted = line.to_string();
22764
22765 if let Some(pos) = redacted.find(" correla") {
22767 let prefix = &redacted[..pos];
22768 if let Some(slash) = prefix.rfind('/') {
22769 let num_start = prefix[..slash]
22770 .rfind(|ch: char| !ch.is_ascii_digit())
22771 .map_or(0, |i| i + 1);
22772 let after_slash = &prefix[slash + 1..];
22773 if !after_slash.is_empty()
22774 && after_slash.chars().all(|ch| ch.is_ascii_digit())
22775 && prefix[num_start..slash]
22776 .chars()
22777 .all(|ch| ch.is_ascii_digit())
22778 && !prefix[num_start..slash].is_empty()
22779 {
22780 redacted = format!("{}N/N{}", &redacted[..num_start], &redacted[pos..]);
22781 }
22782 }
22783 }
22784
22785 if redacted.contains("SHA:") {
22787 if let Some(pos) = redacted.find("SHA: ") {
22788 let sha_start = pos + 5;
22789 let sha_end = redacted[sha_start..]
22790 .find(|c: char| !c.is_ascii_hexdigit())
22791 .map_or(redacted.len(), |i| sha_start + i);
22792 if sha_end > sha_start {
22793 redacted =
22794 format!("{}AAAAAAA{}", &redacted[..sha_start], &redacted[sha_end..]);
22795 }
22796 }
22797 }
22798
22799 if redacted.contains("for ") || redacted.contains("> F ") {
22802 let chars: Vec<char> = redacted.chars().collect();
22803 let mut new_line = String::with_capacity(redacted.len());
22804 let mut i = 0;
22805 while i < chars.len() {
22806 if i + 7 <= chars.len()
22807 && chars[i..i + 7].iter().all(|c| c.is_ascii_hexdigit())
22808 && (i == 0 || !chars[i - 1].is_ascii_alphanumeric())
22809 && (i + 7 >= chars.len() || !chars[i + 7].is_ascii_alphanumeric())
22810 {
22811 if chars[i..i + 7].iter().any(|c| c.is_ascii_alphabetic()) {
22813 new_line.push_str("AAAAAAA");
22814 i += 7;
22815 continue;
22816 }
22817 }
22818 new_line.push(chars[i]);
22819 i += 1;
22820 }
22821 redacted = new_line;
22822 }
22823
22824 if redacted.contains("Date:") {
22826 if let Some(pos) = redacted.find("20") {
22827 let rest = &redacted[pos..];
22828 if rest.len() >= 20 && rest.as_bytes().get(4) == Some(&b'-') {
22829 redacted = format!(
22830 "{}YYYY-MM-DDTHH:MM:SSZ{}",
22831 &redacted[..pos],
22832 &redacted[pos + 20..]
22833 );
22834 }
22835 }
22836 }
22837
22838 result.push_str(&redacted);
22839 result.push('\n');
22840 }
22841
22842 if result.ends_with('\n') && !text.ends_with('\n') {
22844 result.pop();
22845 }
22846
22847 result
22848 }
22849
22850 fn render_app(app: &BvrApp, width: u16, height: u16) -> String {
22851 let mut pool = ftui::GraphemePool::default();
22852 let mut frame = ftui::render::frame::Frame::new(width, height, &mut pool);
22853 app.view(&mut frame);
22854 super::buffer_to_text(&frame.buffer, &pool)
22855 }
22856
22857 fn capture_debug_replay(
22858 app: &BvrApp,
22859 width: u16,
22860 height: u16,
22861 step: &str,
22862 captures: &mut Vec<DebugReplayCapture>,
22863 ) -> String {
22864 let rendered = render_app(app, width, height);
22865 captures.push(DebugReplayCapture {
22866 step: step.to_string(),
22867 mode: app.mode,
22868 focus: app.focus,
22869 selected: app.selected,
22870 width,
22871 height,
22872 trace_len: app.key_trace.len(),
22873 rendered: rendered.clone(),
22874 layout: super::render_layout_debug_report(app, width, height),
22875 hittest: super::render_hittest_debug_report(app, width, height),
22876 });
22877 rendered
22878 }
22879
22880 fn debug_replay_artifact(journey_name: &str, captures: &[DebugReplayCapture]) -> String {
22881 let mut out = String::new();
22882 let _ = writeln!(out, "=== Debug Replay: {journey_name} ===");
22883 let _ = writeln!(out);
22884 for (idx, capture) in captures.iter().enumerate() {
22885 let _ = writeln!(
22886 out,
22887 "--- Step {}: {} | view={} | focus={} | selected={} | size={}x{} | trace-len={} ---",
22888 idx + 1,
22889 capture.step,
22890 capture.mode.label(),
22891 capture.focus.label(),
22892 capture.selected,
22893 capture.width,
22894 capture.height,
22895 capture.trace_len
22896 );
22897 let _ = writeln!(out, "{}", capture.rendered);
22898 let _ = writeln!(out);
22899 let _ = writeln!(out, "{}", capture.layout);
22900 let _ = writeln!(out);
22901 let _ = writeln!(out, "{}", capture.hittest);
22902 let _ = writeln!(out);
22903 }
22904 out
22905 }
22906
22907 fn detail_link_click_point(app: &BvrApp, width: u16, height: u16) -> Option<(u16, u16)> {
22908 let _ = render_app(app, width, height);
22909 let area = app.current_detail_link_row_area()?;
22910 if area.width == 0 || area.height == 0 {
22911 return None;
22912 }
22913 Some((area.x, area.y))
22914 }
22915
22916 fn detail_non_link_click_point(app: &BvrApp, width: u16, height: u16) -> Option<(u16, u16)> {
22917 let _ = render_app(app, width, height);
22918 let detail_area = cached_detail_content_area();
22919 let link_area = app.current_detail_link_row_area()?;
22920 if detail_area.width == 0 || detail_area.height == 0 {
22921 return None;
22922 }
22923
22924 if link_area.y > detail_area.y {
22925 return Some((detail_area.x, detail_area.y));
22926 }
22927
22928 let next_y = link_area.y.saturating_add(1);
22929 if next_y < detail_area.y.saturating_add(detail_area.height) {
22930 return Some((detail_area.x, next_y));
22931 }
22932
22933 None
22934 }
22935
22936 fn header_tab_click_point(
22937 app: &BvrApp,
22938 width: u16,
22939 height: u16,
22940 mode: ViewMode,
22941 ) -> Option<(u16, u16)> {
22942 let _ = render_app(app, width, height);
22943 let tab = super::header_mode_tabs(app, width)
22944 .into_iter()
22945 .find(|tab| tab.mode == mode)?;
22946 Some((tab.rect.x, tab.rect.y))
22947 }
22948
22949 #[test]
22950 fn saturating_scroll_offset_clamps_large_values() {
22951 assert_eq!(saturating_scroll_offset(0), 0);
22952 assert_eq!(saturating_scroll_offset(42), 42);
22953 assert_eq!(
22954 saturating_scroll_offset(usize::from(u16::MAX) + 1),
22955 u16::MAX
22956 );
22957 }
22958
22959 fn rendered_link_urls(app: &BvrApp, width: u16, height: u16) -> Vec<String> {
22960 let mut pool = ftui::GraphemePool::default();
22961 let mut links = ftui::LinkRegistry::new();
22962 let mut frame =
22963 ftui::render::frame::Frame::with_links(width, height, &mut pool, &mut links);
22964 app.view(&mut frame);
22965 let mut link_ids = Vec::new();
22966 for y in 0..height {
22967 for x in 0..width {
22968 if let Some(cell) = frame.buffer.get(x, y) {
22969 let link_id = cell.attrs.link_id();
22970 if link_id != 0 && !link_ids.contains(&link_id) {
22971 link_ids.push(link_id);
22972 }
22973 }
22974 }
22975 }
22976 drop(frame);
22977 link_ids
22978 .into_iter()
22979 .filter_map(|link_id| links.get(link_id).map(ToString::to_string))
22980 .collect()
22981 }
22982
22983 fn init_temp_repo_with_remote(remote: &str) -> tempfile::TempDir {
22984 let dir = tempfile::tempdir().expect("tempdir");
22985 let status = std::process::Command::new("git")
22986 .args(["init", "-q"])
22987 .current_dir(dir.path())
22988 .status()
22989 .expect("git init");
22990 assert!(status.success(), "git init should succeed");
22991
22992 let status = std::process::Command::new("git")
22993 .args(["remote", "add", "origin", remote])
22994 .current_dir(dir.path())
22995 .status()
22996 .expect("git remote add origin");
22997 assert!(status.success(), "git remote add origin should succeed");
22998 dir
22999 }
23000
23001 macro_rules! snapshot_test {
23002 ($name:ident, $mode:expr, $width:literal, $height:literal) => {
23003 #[test]
23004 fn $name() {
23005 let text = render_frame($mode, $width, $height);
23006 insta::assert_snapshot!(text);
23007 }
23008 };
23009 }
23010
23011 snapshot_test!(snap_main_narrow, ViewMode::Main, 60, 30);
23013 snapshot_test!(snap_main_medium, ViewMode::Main, 100, 30);
23014 snapshot_test!(snap_main_wide, ViewMode::Main, 140, 30);
23015
23016 snapshot_test!(snap_board_narrow, ViewMode::Board, 60, 30);
23018 snapshot_test!(snap_board_medium, ViewMode::Board, 100, 30);
23019 snapshot_test!(snap_board_wide, ViewMode::Board, 140, 30);
23020
23021 snapshot_test!(snap_insights_narrow, ViewMode::Insights, 60, 30);
23023 snapshot_test!(snap_insights_medium, ViewMode::Insights, 100, 30);
23024 snapshot_test!(snap_insights_wide, ViewMode::Insights, 140, 30);
23025
23026 snapshot_test!(snap_graph_narrow, ViewMode::Graph, 60, 30);
23028 snapshot_test!(snap_graph_medium, ViewMode::Graph, 100, 30);
23029 snapshot_test!(snap_graph_wide, ViewMode::Graph, 140, 30);
23030
23031 snapshot_test!(snap_history_narrow, ViewMode::History, 60, 30);
23033 snapshot_test!(snap_history_medium, ViewMode::History, 100, 30);
23034 snapshot_test!(snap_history_wide, ViewMode::History, 140, 30);
23035
23036 #[test]
23045 fn keyflow_main_to_board_navigate_return() {
23046 let mut app = new_app(ViewMode::Main, 0);
23047
23048 app.update(key(KeyCode::Char('b')));
23050 assert_eq!(app.mode, ViewMode::Board);
23051
23052 app.update(key(KeyCode::Char('l')));
23054 app.update(key(KeyCode::Char('j')));
23056 app.update(key(KeyCode::Char('j')));
23057
23058 app.update(key(KeyCode::Char('s')));
23060 assert_eq!(app.board_grouping, BoardGrouping::Priority);
23061
23062 app.update(key(KeyCode::Char('q')));
23064 assert_eq!(app.mode, ViewMode::Main);
23065
23066 assert_eq!(app.key_trace.len(), 6);
23068 }
23069
23070 #[test]
23071 fn keyflow_board_search_with_cycling() {
23072 let mut app = new_app(ViewMode::Board, 0);
23073
23074 app.update(key(KeyCode::Char('/')));
23076 assert!(app.board_search_active);
23077
23078 app.update(key(KeyCode::Char('R')));
23080 app.update(key(KeyCode::Char('o')));
23081 assert_eq!(app.board_search_query, "Ro");
23082
23083 app.update(key(KeyCode::Enter));
23085 assert!(!app.board_search_active);
23086
23087 app.update(key(KeyCode::Char('n')));
23089 app.update(key(KeyCode::Char('N')));
23090
23091 app.update(key(KeyCode::Escape));
23093 assert_eq!(app.mode, ViewMode::Main);
23094 }
23095
23096 #[test]
23097 fn keyflow_main_to_insights_explore_return() {
23098 let mut app = new_app(ViewMode::Main, 0);
23099
23100 app.update(key(KeyCode::Char('i')));
23102 assert_eq!(app.mode, ViewMode::Insights);
23103
23104 app.update(key(KeyCode::Char('s')));
23106 assert_ne!(app.insights_panel, InsightsPanel::Bottlenecks);
23107
23108 app.update(key(KeyCode::Char('e')));
23110 assert!(!app.insights_show_explanations);
23111
23112 app.update(key(KeyCode::Char('x')));
23114 assert!(app.insights_show_calc_proof);
23115
23116 app.update(key(KeyCode::Char('l')));
23118 assert_eq!(app.focus, FocusPane::Detail);
23119
23120 app.update(key(KeyCode::Char('q')));
23122 assert_eq!(app.mode, ViewMode::Main);
23123 }
23124
23125 #[test]
23126 fn keyflow_main_to_graph_search_return() {
23127 let mut app = new_app(ViewMode::Main, 0);
23128
23129 app.update(key(KeyCode::Char('g')));
23131 assert_eq!(app.mode, ViewMode::Graph);
23132
23133 app.update(key(KeyCode::Char('j')));
23135 app.update(key(KeyCode::Char('j')));
23136 assert!(app.selected >= 2);
23137
23138 app.update(key(KeyCode::Char('/')));
23140 assert!(app.graph_search_active);
23141 app.update(key(KeyCode::Char('B')));
23142 app.update(key(KeyCode::Enter));
23143 assert!(!app.graph_search_active);
23144
23145 app.update(key(KeyCode::Char('n')));
23147
23148 app.update(key(KeyCode::Escape));
23150 assert_eq!(app.mode, ViewMode::Main);
23151 }
23152
23153 #[test]
23154 fn keyflow_history_bead_to_git_search_graph_jump() {
23155 let mut app = new_app(ViewMode::Main, 0);
23156
23157 app.update(key(KeyCode::Char('h')));
23159 assert_eq!(app.mode, ViewMode::History);
23160
23161 app.update(key(KeyCode::Char('v')));
23163 assert_eq!(app.history_view_mode, HistoryViewMode::Git);
23164
23165 app.update(key(KeyCode::Char('v')));
23167 assert_eq!(app.history_view_mode, HistoryViewMode::Bead);
23168
23169 app.update(key(KeyCode::Char('/')));
23171 assert!(app.history_search_active);
23172 app.update(key(KeyCode::Char('C')));
23173 app.update(key(KeyCode::Enter));
23174 assert!(!app.history_search_active);
23175
23176 app.update(key(KeyCode::Char('n')));
23178
23179 app.update(key(KeyCode::Char('c')));
23181 assert_ne!(app.history_confidence_index, 0);
23182
23183 app.update(key(KeyCode::Char('g')));
23185 assert_eq!(app.mode, ViewMode::Graph);
23186
23187 app.update(key(KeyCode::Char('q')));
23189 assert_eq!(app.mode, ViewMode::Main);
23190 }
23191
23192 #[test]
23193 fn keyflow_filter_navigation_preserves_selection() {
23194 let mut app = new_app(ViewMode::Main, 0);
23195
23196 app.update(key(KeyCode::Char('o')));
23198 assert_eq!(app.list_filter, ListFilter::Open);
23199 let open_count = app.visible_issue_indices().len();
23200
23201 for _ in 0..3 {
23203 app.update(key(KeyCode::Char('j')));
23204 }
23205
23206 app.update(key(KeyCode::Char('c')));
23208 assert_eq!(app.list_filter, ListFilter::Closed);
23209 let closed_count = app.visible_issue_indices().len();
23210 assert_ne!(open_count, closed_count);
23211
23212 app.update(key(KeyCode::Char('a')));
23214 assert_eq!(app.list_filter, ListFilter::All);
23215 }
23216
23217 #[test]
23218 fn keyflow_main_to_actionable_return() {
23219 let mut app = new_app(ViewMode::Main, 0);
23220
23221 app.update(key(KeyCode::Char('a')));
23222 assert_eq!(app.mode, ViewMode::Actionable);
23223 assert!(app.actionable_plan.is_some());
23224 assert_eq!(app.focus, FocusPane::List);
23225
23226 app.update(key(KeyCode::Tab));
23227 assert_eq!(app.focus, FocusPane::Detail);
23228 assert!(app.detail_panel_text().contains("Claim:"));
23229
23230 app.update(key(KeyCode::Char('j')));
23231 app.update(key(KeyCode::Char('a')));
23232 assert_eq!(app.mode, ViewMode::Main);
23233 assert_eq!(app.focus, FocusPane::List);
23234 }
23235
23236 #[test]
23237 fn keyflow_main_search_reacts_to_filter_changes() {
23238 let mut app = new_app(ViewMode::Main, 0);
23239
23240 app.update(key(KeyCode::Char('o')));
23241 assert_eq!(app.list_filter, ListFilter::Open);
23242
23243 app.update(key(KeyCode::Char('/')));
23244 app.update(key(KeyCode::Char('d')));
23245 assert_eq!(selected_issue_id(&app), "A");
23247 app.update(key(KeyCode::Enter));
23248
23249 let open_only = app.list_panel_text();
23250 assert!(open_only.contains("Search: /d (n/N cycles)"));
23251 assert!(open_only.contains("Matches: 1/2"));
23252
23253 app.update(key(KeyCode::Char('a')));
23254 assert_eq!(app.mode, ViewMode::Main);
23255 assert_eq!(app.list_filter, ListFilter::All);
23256
23257 let all_issues = app.list_panel_text();
23258 assert!(all_issues.contains("Search: /d (n/N cycles)"));
23259 assert!(all_issues.contains("Matches: 1/3"));
23261
23262 app.update(key(KeyCode::Char('n')));
23263 assert_eq!(selected_issue_id(&app), "B");
23264 assert!(app.list_panel_text().contains("Matches: 2/3"));
23265 }
23266
23267 #[test]
23268 fn actionable_all_shortcut_clears_filter_before_toggling_view() {
23269 let mut app = new_app(ViewMode::Main, 0);
23270
23271 app.update(key(KeyCode::Char('o')));
23272 assert_eq!(app.list_filter, ListFilter::Open);
23273
23274 app.update(key(KeyCode::Char('a')));
23275 assert_eq!(app.mode, ViewMode::Main);
23276 assert_eq!(app.list_filter, ListFilter::All);
23277
23278 app.update(key(KeyCode::Char('a')));
23279 assert_eq!(app.mode, ViewMode::Actionable);
23280 assert!(app.actionable_plan.is_some());
23281 }
23282
23283 #[test]
23284 fn keyflow_help_then_modal_then_quit() {
23285 let mut app = new_app(ViewMode::Main, 0);
23286
23287 app.update(key(KeyCode::Char('?')));
23289 assert!(app.show_help);
23290
23291 app.update(key(KeyCode::Char('j')));
23293 app.update(key(KeyCode::Char('j')));
23294
23295 app.update(key(KeyCode::Escape));
23297 assert!(!app.show_help);
23298
23299 app.open_tutorial();
23301 assert!(matches!(app.modal_overlay, Some(ModalOverlay::Tutorial)));
23302
23303 app.update(key(KeyCode::Enter));
23305 assert!(app.modal_overlay.is_none());
23306
23307 app.open_confirm_with_resume("Delete?", "Are you sure?", None);
23309 assert!(matches!(
23310 app.modal_overlay,
23311 Some(ModalOverlay::Confirm { .. })
23312 ));
23313
23314 app.update(key(KeyCode::Char('n')));
23316 assert!(app.modal_overlay.is_none());
23317 assert_eq!(app.modal_confirm_result, Some(false));
23318
23319 app.update(key(KeyCode::Escape));
23321 assert!(app.show_quit_confirm);
23322 let cmd = app.update(key(KeyCode::Char('y')));
23323 assert!(matches!(cmd, Cmd::Quit));
23324 }
23325
23326 #[test]
23327 fn keyflow_sort_cycle_persists_across_modes() {
23328 let mut app = new_app(ViewMode::Main, 0);
23329
23330 app.update(key(KeyCode::Char('s')));
23332 let sort_after = app.list_sort;
23333 assert_ne!(sort_after, ListSort::Default);
23334
23335 app.update(key(KeyCode::Char('b')));
23337 app.update(key(KeyCode::Char('q')));
23338 assert_eq!(app.mode, ViewMode::Main);
23339 assert_eq!(app.list_sort, sort_after, "sort should persist");
23340 }
23341
23342 #[test]
23343 fn keyflow_pages_wizard_full_flow() {
23344 let mut app = new_app(ViewMode::Main, 0);
23345
23346 app.open_pages_wizard();
23347 assert!(matches!(
23348 app.modal_overlay,
23349 Some(ModalOverlay::PagesWizard(_))
23350 ));
23351
23352 app.update(key(KeyCode::Enter));
23354 app.update(key(KeyCode::Enter));
23356 app.update(key(KeyCode::Char('c')));
23358 app.update(key(KeyCode::Char('h')));
23360 app.update(key(KeyCode::Enter));
23362
23363 match &app.modal_overlay {
23365 Some(ModalOverlay::PagesWizard(wiz)) => {
23366 assert_eq!(wiz.step, 3);
23367 assert!(!wiz.include_closed, "should have toggled off");
23368 assert!(!wiz.include_history, "should have toggled off");
23369 }
23370 _ => panic!("expected PagesWizard at step 3"),
23371 }
23372
23373 app.update(key(KeyCode::Backspace));
23375 match &app.modal_overlay {
23376 Some(ModalOverlay::PagesWizard(wiz)) => assert_eq!(wiz.step, 2),
23377 _ => panic!("expected step 2"),
23378 }
23379
23380 app.update(key(KeyCode::Enter));
23382 app.update(key(KeyCode::Enter));
23383 assert!(matches!(
23384 app.modal_overlay,
23385 Some(ModalOverlay::Confirm { .. })
23386 ));
23387 app.update(key(KeyCode::Char('y')));
23388 assert!(app.modal_overlay.is_none());
23389 }
23390
23391 #[test]
23392 fn keyflow_full_mode_tour() {
23393 let mut app = new_app(ViewMode::Main, 0);
23395
23396 for (toggle_key, expected_mode) in [
23397 ('b', ViewMode::Board),
23398 ('b', ViewMode::Main), ('i', ViewMode::Insights),
23400 ('i', ViewMode::Main), ('g', ViewMode::Graph),
23402 ('g', ViewMode::Main), ('a', ViewMode::Actionable),
23404 ('a', ViewMode::Main), ('h', ViewMode::History),
23406 ] {
23407 app.update(key(KeyCode::Char(toggle_key)));
23408 assert_eq!(
23409 app.mode, expected_mode,
23410 "after pressing '{toggle_key}' expected {expected_mode:?}"
23411 );
23412 }
23413
23414 app.update(key(KeyCode::Escape));
23416 assert_eq!(app.mode, ViewMode::Main);
23417
23418 assert_eq!(app.key_trace.len(), 10);
23420 }
23421
23422 #[test]
23423 fn keyflow_detail_dep_navigation_journey() {
23424 let mut app = new_app(ViewMode::Main, 0);
23425
23426 app.update(key(KeyCode::Char('j'))); assert_eq!(selected_issue_id(&app), "B");
23429
23430 app.update(key(KeyCode::Char('g')));
23432 assert_eq!(app.mode, ViewMode::Graph);
23433
23434 app.update(key(KeyCode::Tab));
23436 assert_eq!(app.focus, FocusPane::Detail);
23437
23438 app.update(key(KeyCode::Char('J')));
23440 app.update(key(KeyCode::Char('K')));
23441
23442 app.update(key(KeyCode::Tab));
23444 assert_eq!(app.focus, FocusPane::List);
23445
23446 app.update(key(KeyCode::Char('q')));
23447 assert_eq!(app.mode, ViewMode::Main);
23448 }
23449
23450 #[test]
23458 fn snap_after_board_search() {
23459 let mut app = new_app(ViewMode::Board, 0);
23460 app.update(key(KeyCode::Char('/')));
23461 app.update(key(KeyCode::Char('B')));
23462 app.update(key(KeyCode::Enter));
23463
23464 let mut pool = ftui::GraphemePool::default();
23465 let mut frame = ftui::render::frame::Frame::new(100, 30, &mut pool);
23466 app.view(&mut frame);
23467 let text = super::buffer_to_text(&frame.buffer, &pool);
23468 insta::assert_snapshot!(text);
23469 }
23470
23471 #[test]
23472 fn snap_after_insights_panel_cycle() {
23473 let mut app = new_app(ViewMode::Insights, 0);
23474 app.update(key(KeyCode::Char('s')));
23476 app.update(key(KeyCode::Char('s')));
23477 app.update(key(KeyCode::Char('s')));
23478
23479 let mut pool = ftui::GraphemePool::default();
23480 let mut frame = ftui::render::frame::Frame::new(100, 30, &mut pool);
23481 app.view(&mut frame);
23482 let text = super::buffer_to_text(&frame.buffer, &pool);
23483 insta::assert_snapshot!(text);
23484 }
23485
23486 #[test]
23487 fn snap_help_overlay() {
23488 let mut app = new_app(ViewMode::Main, 0);
23489 app.update(key(KeyCode::Char('?')));
23490
23491 let mut pool = ftui::GraphemePool::default();
23492 let mut frame = ftui::render::frame::Frame::new(100, 30, &mut pool);
23493 app.view(&mut frame);
23494 let text = super::buffer_to_text(&frame.buffer, &pool);
23495 insta::assert_snapshot!(text);
23496 }
23497
23498 #[test]
23499 fn snap_history_git_mode() {
23500 let app = history_app_with_git_cache(HistoryViewMode::Git, 0);
23501
23502 let text = render_app(&app, 100, 30);
23503 insta::assert_snapshot!(text);
23504 }
23505
23506 #[test]
23507 fn snap_quit_confirm_modal() {
23508 let mut app = new_app(ViewMode::Main, 0);
23509 app.update(key(KeyCode::Escape));
23510 assert!(app.show_quit_confirm);
23511
23512 let mut pool = ftui::GraphemePool::default();
23513 let mut frame = ftui::render::frame::Frame::new(100, 30, &mut pool);
23514 app.view(&mut frame);
23515 let text = super::buffer_to_text(&frame.buffer, &pool);
23516 insta::assert_snapshot!(text);
23517 }
23518
23519 #[test]
23520 fn snap_confirm_modal() {
23521 let mut app = new_app(ViewMode::Main, 0);
23522 app.open_confirm_with_resume(
23523 "Export Pages?",
23524 "Finalize the current pages export settings?",
23525 None,
23526 );
23527
23528 let text = render_app(&app, 100, 30);
23529 insta::assert_snapshot!(text);
23530 }
23531
23532 #[test]
23533 fn snap_filter_applied() {
23534 let mut app = new_app(ViewMode::Main, 0);
23535 app.update(key(KeyCode::Char('o'))); let text = render_app(&app, 100, 30);
23538 insta::assert_snapshot!(text);
23539 }
23540
23541 #[test]
23542 fn snap_main_with_priority_hints() {
23543 let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
23544 app.update(key(KeyCode::Char('p')));
23545
23546 let text = render_app(&app, 100, 30);
23547 insta::assert_snapshot!(text);
23548 }
23549
23550 #[test]
23551 fn snap_board_detail_focus() {
23552 let mut app = new_app(ViewMode::Board, 1);
23553 app.update(key(KeyCode::Tab));
23554
23555 let text = render_app(&app, 100, 30);
23556 insta::assert_snapshot!(text);
23557 }
23558
23559 #[test]
23560 fn snap_insights_heatmap_drill() {
23561 let mut app = new_app(ViewMode::Insights, 0);
23562 app.update(key(KeyCode::Char('m')));
23563 app.update(key(KeyCode::Enter));
23564
23565 let text = render_app(&app, 100, 30);
23566 insta::assert_snapshot!(text);
23567 }
23568
23569 #[test]
23570 fn snap_graph_detail_focus() {
23571 let mut app = new_app(ViewMode::Graph, 1);
23572 app.update(key(KeyCode::Tab));
23573
23574 let text = render_app(&app, 100, 30);
23575 insta::assert_snapshot!(text);
23576 }
23577
23578 #[test]
23579 fn snap_actionable_detail_focus() {
23580 let mut app = new_app(ViewMode::Main, 0);
23581 app.update(key(KeyCode::Char('a')));
23582 app.update(key(KeyCode::Tab));
23583
23584 let text = render_app(&app, 100, 30);
23585 insta::assert_snapshot!(text);
23586 }
23587
23588 fn labeled_issues() -> Vec<Issue> {
23591 vec![
23592 Issue {
23593 id: "A".to_string(),
23594 title: "Feature work".to_string(),
23595 status: "open".to_string(),
23596 issue_type: "feature".to_string(),
23597 priority: 1,
23598 labels: vec!["backend".to_string(), "urgent".to_string()],
23599 ..Issue::default()
23600 },
23601 Issue {
23602 id: "B".to_string(),
23603 title: "Bug fix".to_string(),
23604 status: "open".to_string(),
23605 issue_type: "bug".to_string(),
23606 priority: 2,
23607 labels: vec!["backend".to_string()],
23608 ..Issue::default()
23609 },
23610 Issue {
23611 id: "C".to_string(),
23612 title: "UI polish".to_string(),
23613 status: "open".to_string(),
23614 issue_type: "task".to_string(),
23615 priority: 3,
23616 labels: vec!["frontend".to_string()],
23617 ..Issue::default()
23618 },
23619 ]
23620 }
23621
23622 #[test]
23623 fn attention_view_toggle_and_state() {
23624 let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
23625
23626 app.update(key(KeyCode::Char('!')));
23628 assert!(matches!(app.mode, ViewMode::Attention));
23629 assert!(app.attention_result.is_some());
23630 assert_eq!(app.attention_cursor, 0);
23631
23632 app.update(key(KeyCode::Char('!')));
23634 assert!(matches!(app.mode, ViewMode::Main));
23635 }
23636
23637 #[test]
23638 fn attention_view_navigation() {
23639 let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
23640 app.update(key(KeyCode::Char('!')));
23641 assert!(matches!(app.mode, ViewMode::Attention));
23642
23643 let label_count = app.attention_result.as_ref().unwrap().labels.len();
23644 assert!(label_count >= 2, "should have at least 2 labels");
23645
23646 app.update(key(KeyCode::Char('j')));
23648 assert_eq!(app.attention_cursor, 1);
23649
23650 app.update(key(KeyCode::Char('k')));
23652 assert_eq!(app.attention_cursor, 0);
23653
23654 app.update(key(KeyCode::Char('k')));
23656 assert_eq!(app.attention_cursor, 0);
23657 }
23658
23659 #[test]
23660 fn attention_view_renders_list_and_detail() {
23661 let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
23662 app.update(key(KeyCode::Char('!')));
23663
23664 let list = app.list_panel_text();
23665 assert!(list.contains("Rank"));
23666 assert!(list.contains("Label"));
23667 assert!(list.contains("Score"));
23668
23669 let detail = app.detail_panel_text();
23670 assert!(detail.contains("Label:"));
23671 assert!(detail.contains("Attention Score:"));
23672 assert!(detail.contains("Breakdown:"));
23673 }
23674
23675 #[test]
23676 fn attention_view_empty_issues_no_panic() {
23677 let mut app = new_app_with_issues(ViewMode::Main, 0, vec![]);
23678 app.update(key(KeyCode::Char('!')));
23679 assert!(matches!(app.mode, ViewMode::Attention));
23680
23681 let list = app.list_panel_text();
23682 assert!(list.contains("no issues loaded"));
23684
23685 app.update(key(KeyCode::Char('j')));
23687 app.update(key(KeyCode::Char('k')));
23688 }
23689
23690 #[test]
23693 fn refresh_keybinding_does_not_panic() {
23694 let mut app = new_app(ViewMode::Main, 0);
23695
23696 app.update(Msg::KeyPress(KeyCode::Char('r'), Modifiers::CTRL));
23698 assert!(matches!(app.mode, ViewMode::Main));
23699
23700 app.update(key(KeyCode::F(5)));
23702 assert!(matches!(app.mode, ViewMode::Main));
23703 }
23704
23705 #[test]
23706 fn refresh_preserves_mode() {
23707 let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
23708 app.update(key(KeyCode::Char('!')));
23709 assert!(matches!(app.mode, ViewMode::Attention));
23710
23711 app.update(Msg::KeyPress(KeyCode::Char('r'), Modifiers::CTRL));
23713 assert!(matches!(app.mode, ViewMode::Attention));
23714 }
23715
23716 #[test]
23719 fn priority_hints_toggle() {
23720 let mut app = new_app(ViewMode::Main, 0);
23721 assert!(!app.priority_hints_visible);
23722
23723 app.update(key(KeyCode::Char('p')));
23724 assert!(app.priority_hints_visible);
23725
23726 app.update(key(KeyCode::Char('p')));
23727 assert!(!app.priority_hints_visible);
23728 }
23729
23730 #[test]
23731 fn priority_hints_show_breakdown_in_detail() {
23732 let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
23733 app.update(key(KeyCode::Char('p')));
23734 assert!(app.priority_hints_visible);
23735
23736 let detail = app.detail_panel_text();
23737 assert!(detail.contains("Priority Hints"));
23738 assert!(detail.contains("Triage Score:") || detail.contains("not in triage"));
23739 }
23740
23741 #[test]
23742 fn priority_hints_only_in_main_mode() {
23743 let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
23744
23745 app.update(key(KeyCode::Char('b')));
23747 assert!(matches!(app.mode, ViewMode::Board));
23748 app.update(key(KeyCode::Char('p')));
23749 assert!(
23750 !app.priority_hints_visible,
23751 "p should not toggle hints in Board mode"
23752 );
23753 }
23754
23755 #[test]
23758 fn copy_issue_id_sets_status_msg() {
23759 let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
23760
23761 app.update(key(KeyCode::Char('C')));
23763 assert!(
23764 app.status_msg.contains("Copied") || app.status_msg.contains("Clipboard"),
23765 "status_msg should indicate clipboard result: {}",
23766 app.status_msg
23767 );
23768 }
23769
23770 #[test]
23771 fn export_markdown_creates_temp_file() {
23772 let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
23773 app.update(key(KeyCode::Char('x')));
23774 assert!(
23775 app.status_msg.contains("Exported"),
23776 "should confirm export: {}",
23777 app.status_msg
23778 );
23779 }
23780
23781 #[test]
23782 fn status_msg_cleared_on_next_key() {
23783 let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
23784 app.status_msg = "Test message".to_string();
23785
23786 app.update(key(KeyCode::Char('j')));
23788 assert!(app.status_msg.is_empty());
23789 }
23790
23791 #[test]
23794 fn t_key_enters_time_travel_mode_with_input_prompt() {
23795 let mut app = new_app(ViewMode::Main, 0);
23796 app.update(key(KeyCode::Char('t')));
23797 assert_eq!(app.mode, ViewMode::TimeTravelDiff);
23798 assert!(app.time_travel_input_active);
23799 assert!(app.time_travel_ref_input.is_empty());
23800 }
23801
23802 #[test]
23803 fn time_travel_escape_from_empty_input_returns_to_main() {
23804 let mut app = new_app(ViewMode::Main, 0);
23805 app.update(key(KeyCode::Char('t')));
23806 assert_eq!(app.mode, ViewMode::TimeTravelDiff);
23807 app.update(key(KeyCode::Escape));
23808 assert_eq!(app.mode, ViewMode::Main);
23809 assert!(!app.time_travel_input_active);
23810 }
23811
23812 #[test]
23813 fn time_travel_input_accepts_characters() {
23814 let mut app = new_app(ViewMode::Main, 0);
23815 app.update(key(KeyCode::Char('t')));
23816 app.update(key(KeyCode::Char('H')));
23817 app.update(key(KeyCode::Char('E')));
23818 app.update(key(KeyCode::Char('A')));
23819 app.update(key(KeyCode::Char('D')));
23820 assert_eq!(app.time_travel_ref_input, "HEAD");
23821 }
23822
23823 #[test]
23824 fn time_travel_backspace_removes_char() {
23825 let mut app = new_app(ViewMode::Main, 0);
23826 app.update(key(KeyCode::Char('t')));
23827 app.update(key(KeyCode::Char('a')));
23828 app.update(key(KeyCode::Char('b')));
23829 app.update(key(KeyCode::Backspace));
23830 assert_eq!(app.time_travel_ref_input, "a");
23831 }
23832
23833 #[test]
23834 fn time_travel_enter_with_empty_ref_returns_to_main() {
23835 let mut app = new_app(ViewMode::Main, 0);
23836 app.update(key(KeyCode::Char('t')));
23837 app.update(key(KeyCode::Enter));
23838 assert_eq!(app.mode, ViewMode::Main);
23840 assert!(!app.time_travel_input_active);
23841 }
23842
23843 #[test]
23844 fn time_travel_empty_ref_with_existing_diff_stays_in_mode() {
23845 let mut app = new_app(ViewMode::Main, 0);
23846 app.mode = ViewMode::TimeTravelDiff;
23847 app.time_travel_input_active = true;
23848 app.time_travel_last_ref = Some("HEAD~1".to_string());
23849 app.time_travel_diff = Some(crate::analysis::diff::compare_snapshots(
23850 &[],
23851 &app.analyzer.issues,
23852 ));
23853
23854 app.update(key(KeyCode::Enter));
23855
23856 assert_eq!(app.mode, ViewMode::TimeTravelDiff);
23857 assert!(!app.time_travel_input_active);
23858 assert!(app.time_travel_diff.is_some());
23859 assert_eq!(app.time_travel_last_ref.as_deref(), Some("HEAD~1"));
23860 assert_eq!(app.status_msg, "Time-travel: empty ref, cancelled");
23861 }
23862
23863 #[test]
23864 fn time_travel_invalid_ref_sets_error_status_and_keeps_mode() {
23865 let mut app = new_app(ViewMode::Main, 0);
23866 app.update(key(KeyCode::Char('t')));
23867 for ch in "__definitely_not_a_real_ref__".chars() {
23868 app.update(key(KeyCode::Char(ch)));
23869 }
23870
23871 app.update(key(KeyCode::Enter));
23872
23873 assert_eq!(app.mode, ViewMode::TimeTravelDiff);
23874 assert!(!app.time_travel_input_active);
23875 assert!(app.time_travel_diff.is_none());
23876 assert_eq!(
23877 app.time_travel_last_ref.as_deref(),
23878 Some("__definitely_not_a_real_ref__")
23879 );
23880 assert!(
23881 app.status_msg
23882 .contains("could not resolve '__definitely_not_a_real_ref__'"),
23883 "status should explain invalid ref: {}",
23884 app.status_msg
23885 );
23886 }
23887
23888 #[test]
23889 fn time_travel_invalid_ref_preserves_existing_diff() {
23890 let mut app = new_app(ViewMode::Main, 0);
23891 let existing = crate::analysis::diff::compare_snapshots(&[], &app.analyzer.issues);
23892 let existing_new_count = existing.new_issues.as_ref().map_or(0, Vec::len);
23893 app.mode = ViewMode::TimeTravelDiff;
23894 app.time_travel_input_active = true;
23895 app.time_travel_last_ref = Some("HEAD~1".to_string());
23896 app.time_travel_diff = Some(existing);
23897 app.time_travel_ref_input = "__still_not_a_real_ref__".to_string();
23898
23899 app.update(key(KeyCode::Enter));
23900
23901 assert_eq!(app.mode, ViewMode::TimeTravelDiff);
23902 assert!(!app.time_travel_input_active);
23903 let retained_new_count = app
23904 .time_travel_diff
23905 .as_ref()
23906 .and_then(|diff| diff.new_issues.as_ref())
23907 .map_or(0, Vec::len);
23908 assert_eq!(retained_new_count, existing_new_count);
23909 assert_eq!(
23910 app.time_travel_last_ref.as_deref(),
23911 Some("__still_not_a_real_ref__")
23912 );
23913 assert!(
23914 app.status_msg
23915 .contains("could not resolve '__still_not_a_real_ref__'"),
23916 "status should explain invalid ref: {}",
23917 app.status_msg
23918 );
23919 }
23920
23921 #[test]
23922 fn time_travel_toggle_off() {
23923 let mut app = new_app(ViewMode::Main, 0);
23924 app.mode = ViewMode::TimeTravelDiff;
23926 app.time_travel_input_active = false;
23927 app.time_travel_diff = Some(crate::analysis::diff::compare_snapshots(
23928 &app.analyzer.issues.clone(),
23929 &app.analyzer.issues,
23930 ));
23931 app.update(key(KeyCode::Char('t')));
23933 assert_eq!(app.mode, ViewMode::Main);
23934 }
23935
23936 #[test]
23937 fn time_travel_jk_navigates_categories() {
23938 let mut app = new_app(ViewMode::Main, 0);
23939 app.mode = ViewMode::TimeTravelDiff;
23940 app.time_travel_input_active = false;
23941 app.time_travel_diff = Some(crate::analysis::diff::compare_snapshots(
23943 &[],
23944 &app.analyzer.issues,
23945 ));
23946 app.update(key(KeyCode::Char('j')));
23947 app.update(key(KeyCode::Char('k')));
23948 }
23950
23951 #[test]
23952 fn time_travel_list_text_shows_prompt_when_input_active() {
23953 let mut app = new_app(ViewMode::Main, 0);
23954 app.mode = ViewMode::TimeTravelDiff;
23955 app.time_travel_input_active = true;
23956 app.time_travel_ref_input = "HEAD~3".to_string();
23957 let text = app.time_travel_list_text();
23958 assert!(text.contains("HEAD~3"), "should show input: {text}");
23959 }
23960
23961 #[test]
23962 fn time_travel_list_text_shows_no_diff_when_empty() {
23963 let mut app = new_app(ViewMode::Main, 0);
23964 app.mode = ViewMode::TimeTravelDiff;
23965 app.time_travel_input_active = false;
23966 let text = app.time_travel_list_text();
23967 assert!(
23968 text.contains("No diff loaded"),
23969 "should show no-diff message: {text}"
23970 );
23971 }
23972
23973 #[test]
23974 fn time_travel_with_diff_shows_summary() {
23975 let mut app = new_app(ViewMode::Main, 0);
23976 app.mode = ViewMode::TimeTravelDiff;
23977 app.time_travel_input_active = false;
23978 app.time_travel_last_ref = Some("HEAD~1".to_string());
23979 app.time_travel_diff = Some(crate::analysis::diff::compare_snapshots(
23981 &[],
23982 &app.analyzer.issues,
23983 ));
23984 let text = app.time_travel_list_text();
23985 assert!(text.contains("HEAD~1"), "should show ref: {text}");
23986 assert!(
23987 text.contains("New issues"),
23988 "should show new issues category: {text}"
23989 );
23990 }
23991
23992 fn make_sprint(id: &str, name: &str, bead_ids: Vec<&str>) -> Sprint {
23995 let now = sprint_reference_now();
23996 Sprint {
23997 id: id.to_string(),
23998 name: name.to_string(),
23999 start_date: Some(now - chrono::Duration::days(7)),
24000 end_date: Some(now + chrono::Duration::days(7)),
24001 bead_ids: bead_ids.into_iter().map(String::from).collect(),
24002 }
24003 }
24004
24005 #[test]
24006 fn s_key_toggles_sprint_mode() {
24007 let mut app = new_app(ViewMode::Main, 0);
24008 app.update(key(KeyCode::Char('S')));
24009 assert_eq!(app.mode, ViewMode::Sprint, "S should enter Sprint mode");
24010 app.update(key(KeyCode::Char('S')));
24011 assert_eq!(app.mode, ViewMode::Main, "S again should return to Main");
24012 }
24013
24014 #[test]
24015 fn sprint_list_shows_no_sprints_message() {
24016 let mut app = new_app(ViewMode::Main, 0);
24017 app.mode = ViewMode::Sprint;
24018 app.sprint_data = Vec::new();
24019 let text = app.sprint_list_text();
24020 assert!(
24021 text.contains("No sprints found"),
24022 "should show no-sprints message: {text}"
24023 );
24024 }
24025
24026 #[test]
24027 fn sprint_list_shows_sprint_summary() {
24028 let mut app = new_app(ViewMode::Main, 0);
24029 app.mode = ViewMode::Sprint;
24030 app.sprint_data = vec![make_sprint("s1", "Sprint Alpha", vec!["A", "B", "C"])];
24032 let text = app.sprint_list_text();
24033 assert!(
24034 text.contains("Sprint Alpha"),
24035 "should show sprint name: {text}"
24036 );
24037 assert!(text.contains("3 issues"), "should show issue count: {text}");
24038 assert!(text.contains("ACTIVE"), "should show active status: {text}");
24039 }
24040
24041 #[test]
24042 fn sprint_detail_shows_issue_list() {
24043 let mut app = new_app(ViewMode::Main, 0);
24044 app.mode = ViewMode::Sprint;
24045 app.sprint_data = vec![make_sprint("s1", "Sprint Alpha", vec!["A", "B", "C"])];
24046 app.sprint_cursor = 0;
24047 let text = app.sprint_detail_text();
24048 assert!(
24049 text.contains("SPRINT: Sprint Alpha"),
24050 "should show sprint name: {text}"
24051 );
24052 assert!(text.contains("ACTIVE"), "should show active status: {text}");
24053 assert!(
24055 text.contains("Issues:") || text.contains("bead(s)"),
24056 "should show issue summary: {text}"
24057 );
24058 }
24059
24060 #[test]
24061 fn sprint_jk_navigates_sprints() {
24062 let mut app = new_app(ViewMode::Main, 0);
24063 app.mode = ViewMode::Sprint;
24064 app.sprint_data = vec![
24065 make_sprint("s1", "Sprint 1", vec!["A"]),
24066 make_sprint("s2", "Sprint 2", vec!["B"]),
24067 ];
24068 app.focus = FocusPane::List;
24069 assert_eq!(app.sprint_cursor, 0);
24070 app.update(key(KeyCode::Char('j')));
24071 assert_eq!(app.sprint_cursor, 1, "j should move to next sprint");
24072 app.update(key(KeyCode::Char('k')));
24073 assert_eq!(app.sprint_cursor, 0, "k should move back");
24074 }
24075
24076 #[test]
24077 fn sprint_jk_navigates_issues_in_detail() {
24078 let mut app = new_app(ViewMode::Main, 0);
24079 app.mode = ViewMode::Sprint;
24080 app.sprint_data = vec![make_sprint("s1", "Sprint 1", vec!["A", "B", "C"])];
24081 app.focus = FocusPane::Detail;
24082 assert_eq!(app.sprint_issue_cursor, 0);
24083 app.update(key(KeyCode::Char('j')));
24084 assert_eq!(
24085 app.sprint_issue_cursor, 1,
24086 "j in detail should move issue cursor"
24087 );
24088 app.update(key(KeyCode::Char('k')));
24089 assert_eq!(app.sprint_issue_cursor, 0, "k should move back");
24090 }
24091
24092 #[test]
24093 fn sprint_escape_returns_to_main() {
24094 let mut app = new_app(ViewMode::Sprint, 0);
24095 app.sprint_data = vec![make_sprint("s1", "Sprint 1", vec!["A"])];
24096 app.update(key(KeyCode::Char('S')));
24097 assert_eq!(
24098 app.mode,
24099 ViewMode::Main,
24100 "S in Sprint mode should return to Main"
24101 );
24102 }
24103
24104 #[test]
24105 fn sprint_detail_shows_progress_bar() {
24106 let mut app = new_app(ViewMode::Main, 0);
24107 app.mode = ViewMode::Sprint;
24108 app.sprint_data = vec![make_sprint("s1", "Sprint Alpha", vec!["A", "B", "C"])];
24110 app.sprint_cursor = 0;
24111 let text = app.sprint_detail_text();
24112 assert!(
24113 text.contains("Progress:"),
24114 "should show progress bar: {text}"
24115 );
24116 assert!(text.contains('%'), "should show percentage: {text}");
24117 }
24118
24119 #[test]
24120 fn sprint_cursor_reset_on_sprint_change() {
24121 let mut app = new_app(ViewMode::Main, 0);
24122 app.mode = ViewMode::Sprint;
24123 app.sprint_data = vec![
24124 make_sprint("s1", "Sprint 1", vec!["A", "B"]),
24125 make_sprint("s2", "Sprint 2", vec!["C"]),
24126 ];
24127 app.focus = FocusPane::List;
24128 app.sprint_issue_cursor = 1;
24129 app.update(key(KeyCode::Char('j')));
24131 assert_eq!(
24132 app.sprint_issue_cursor, 0,
24133 "issue cursor should reset on sprint change"
24134 );
24135 }
24136
24137 #[test]
24140 fn quote_key_opens_recipe_picker() {
24141 let mut app = new_app(ViewMode::Main, 0);
24142 app.update(key(KeyCode::Char('\'')));
24143 assert!(
24144 matches!(app.modal_overlay, Some(ModalOverlay::RecipePicker { .. })),
24145 "' should open recipe picker"
24146 );
24147 }
24148
24149 #[test]
24150 fn recipe_picker_jk_navigates() {
24151 let mut app = new_app(ViewMode::Main, 0);
24152 app.update(key(KeyCode::Char('\'')));
24153 if let Some(ModalOverlay::RecipePicker { cursor, .. }) = &app.modal_overlay {
24154 assert_eq!(*cursor, 0);
24155 }
24156 app.update(key(KeyCode::Char('j')));
24157 if let Some(ModalOverlay::RecipePicker { cursor, .. }) = &app.modal_overlay {
24158 assert_eq!(*cursor, 1, "j should advance cursor");
24159 }
24160 app.update(key(KeyCode::Char('k')));
24161 if let Some(ModalOverlay::RecipePicker { cursor, .. }) = &app.modal_overlay {
24162 assert_eq!(*cursor, 0, "k should go back");
24163 }
24164 }
24165
24166 #[test]
24167 fn recipe_picker_escape_closes() {
24168 let mut app = new_app(ViewMode::Main, 0);
24169 app.update(key(KeyCode::Char('\'')));
24170 assert!(app.modal_overlay.is_some());
24171 app.update(key(KeyCode::Escape));
24172 assert!(
24173 app.modal_overlay.is_none(),
24174 "Esc should close recipe picker"
24175 );
24176 }
24177
24178 #[test]
24179 fn recipe_picker_enter_selects_and_closes() {
24180 let mut app = new_app(ViewMode::Main, 0);
24181 app.update(key(KeyCode::Char('\'')));
24182 app.update(key(KeyCode::Enter));
24183 assert!(
24184 app.modal_overlay.is_none(),
24185 "Enter should close recipe picker"
24186 );
24187 assert!(
24188 app.status_msg.contains("Recipe:"),
24189 "should show recipe name in status: {}",
24190 app.status_msg
24191 );
24192 }
24193
24194 #[test]
24195 fn capital_l_opens_label_picker() {
24196 let mut app = new_app(ViewMode::Main, 0);
24197 app.update(key(KeyCode::Char('L')));
24198 assert!(
24199 matches!(app.modal_overlay, Some(ModalOverlay::LabelPicker { .. })),
24200 "L should open label picker"
24201 );
24202 if let Some(ModalOverlay::LabelPicker { items, .. }) = &app.modal_overlay {
24204 assert!(!items.is_empty(), "should have labels from issues");
24205 }
24206 }
24207
24208 #[test]
24209 fn label_picker_enter_filters_by_label() {
24210 let mut app = new_app(ViewMode::Main, 0);
24211 app.update(key(KeyCode::Char('L')));
24212 app.update(key(KeyCode::Enter));
24213 assert!(
24214 app.modal_label_filter.is_some(),
24215 "Enter in label picker should set label filter"
24216 );
24217 assert!(
24218 app.status_msg.contains("Filtering by label"),
24219 "should show filter status: {}",
24220 app.status_msg
24221 );
24222 }
24223
24224 #[test]
24225 fn label_picker_filter_updates_selection_before_next_key() {
24226 let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
24227 app.selected = 1;
24228
24229 app.update(key(KeyCode::Char('L')));
24230 app.update(key(KeyCode::Down));
24231 app.update(key(KeyCode::Enter));
24232
24233 assert_eq!(app.modal_label_filter.as_deref(), Some("frontend"));
24234 assert_eq!(selected_issue_id(&app), "C");
24235 assert_eq!(
24236 app.selected_issue().map(|issue| issue.id.as_str()),
24237 Some("C")
24238 );
24239 assert!(
24240 app.list_panel_text()
24241 .lines()
24242 .any(|line| line.contains("▸") && line.contains(" C "))
24243 );
24244 }
24245
24246 #[test]
24247 fn label_filter_clears_on_all() {
24248 let mut app = new_app(ViewMode::Main, 0);
24249 app.modal_label_filter = Some("core".to_string());
24250 assert!(app.has_active_filter());
24251 app.set_list_filter(ListFilter::All);
24252 assert!(
24253 app.modal_label_filter.is_none(),
24254 "All filter should clear label filter"
24255 );
24256 }
24257
24258 #[test]
24259 fn w_key_opens_repo_picker_or_status_msg() {
24260 let mut app = new_app(ViewMode::Main, 0);
24261 app.update(key(KeyCode::Char('w')));
24262 if app.modal_overlay.is_some() {
24265 assert!(
24266 matches!(app.modal_overlay, Some(ModalOverlay::RepoPicker { .. })),
24267 "w should open repo picker when repos exist"
24268 );
24269 } else {
24270 assert!(
24272 app.status_msg.contains("repo") || app.status_msg.contains("workspace"),
24273 "should indicate no repos: {}",
24274 app.status_msg
24275 );
24276 }
24277 }
24278
24279 #[test]
24280 fn repo_picker_filter_updates_selection_before_next_key() {
24281 let mut app = new_app(ViewMode::Main, 2);
24282
24283 app.update(key(KeyCode::Char('w')));
24284 app.update(key(KeyCode::Enter));
24285
24286 assert_eq!(app.modal_repo_filter.as_deref(), Some("viewer"));
24287 assert_eq!(selected_issue_id(&app), "A");
24288 assert_eq!(
24289 app.selected_issue().map(|issue| issue.id.as_str()),
24290 Some("A")
24291 );
24292 assert!(
24293 app.list_panel_text()
24294 .lines()
24295 .any(|line| line.contains("▸") && line.contains(" A "))
24296 );
24297 }
24298
24299 #[test]
24300 fn sprint_with_no_matching_issues_shows_message() {
24301 let mut app = new_app(ViewMode::Main, 0);
24302 app.mode = ViewMode::Sprint;
24303 app.sprint_data = vec![make_sprint(
24304 "s1",
24305 "Sprint Ghost",
24306 vec!["NONEXISTENT-1", "NONEXISTENT-2"],
24307 )];
24308 app.sprint_cursor = 0;
24309 let text = app.sprint_detail_text();
24310 assert!(
24311 text.contains("none matched"),
24312 "should say no issues matched: {text}"
24313 );
24314 }
24315
24316 snapshot_test!(snap_actionable_narrow, ViewMode::Actionable, 60, 30);
24323 snapshot_test!(snap_actionable_medium, ViewMode::Actionable, 100, 30);
24324 snapshot_test!(snap_actionable_wide, ViewMode::Actionable, 140, 30);
24325
24326 snapshot_test!(snap_tree_narrow, ViewMode::Tree, 60, 30);
24327 snapshot_test!(snap_tree_medium, ViewMode::Tree, 100, 30);
24328 snapshot_test!(snap_tree_wide, ViewMode::Tree, 140, 30);
24329
24330 snapshot_test!(
24331 snap_label_dashboard_narrow,
24332 ViewMode::LabelDashboard,
24333 60,
24334 30
24335 );
24336 snapshot_test!(
24337 snap_label_dashboard_medium,
24338 ViewMode::LabelDashboard,
24339 100,
24340 30
24341 );
24342 snapshot_test!(snap_label_dashboard_wide, ViewMode::LabelDashboard, 140, 30);
24343
24344 snapshot_test!(snap_flow_matrix_narrow, ViewMode::FlowMatrix, 60, 30);
24345 snapshot_test!(snap_flow_matrix_medium, ViewMode::FlowMatrix, 100, 30);
24346 snapshot_test!(snap_flow_matrix_wide, ViewMode::FlowMatrix, 140, 30);
24347
24348 snapshot_test!(snap_sprint_narrow, ViewMode::Sprint, 60, 30);
24349 snapshot_test!(snap_sprint_medium, ViewMode::Sprint, 100, 30);
24350 snapshot_test!(snap_sprint_wide, ViewMode::Sprint, 140, 30);
24351
24352 snapshot_test!(snap_time_travel_narrow, ViewMode::TimeTravelDiff, 60, 30);
24353 snapshot_test!(snap_time_travel_medium, ViewMode::TimeTravelDiff, 100, 30);
24354 snapshot_test!(snap_time_travel_wide, ViewMode::TimeTravelDiff, 140, 30);
24355
24356 #[test]
24359 fn snap_attention_detail_focus() {
24360 let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
24361 app.update(key(KeyCode::Char('!')));
24362 app.update(key(KeyCode::Tab));
24363 let text = render_app(&app, 100, 30);
24364 insta::assert_snapshot!(text);
24365 }
24366
24367 #[test]
24368 fn snap_tree_detail_focus() {
24369 let mut app = new_app(ViewMode::Main, 0);
24370 app.update(key(KeyCode::Char('T')));
24371 app.update(key(KeyCode::Tab));
24372 let text = render_app(&app, 100, 30);
24373 insta::assert_snapshot!(text);
24374 }
24375
24376 #[test]
24377 fn snap_label_dashboard_detail_focus() {
24378 let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
24379 app.update(key(KeyCode::Char('[')));
24380 app.update(key(KeyCode::Tab));
24381 let text = render_app(&app, 100, 30);
24382 insta::assert_snapshot!(text);
24383 }
24384
24385 #[test]
24386 fn snap_flow_matrix_detail_focus() {
24387 let mut app = new_app(ViewMode::Main, 0);
24388 app.update(key(KeyCode::Char(']')));
24389 app.update(key(KeyCode::Tab));
24390 let text = render_app(&app, 100, 30);
24391 insta::assert_snapshot!(text);
24392 }
24393
24394 #[test]
24395 fn snap_sprint_detail_focus() {
24396 let mut app = new_app(ViewMode::Main, 0);
24397 app.mode = ViewMode::Sprint;
24398 app.sprint_data = vec![make_sprint("s1", "Sprint Alpha", vec!["A", "B", "C"])];
24399 app.focus = FocusPane::Detail;
24400 let text = render_app(&app, 100, 30);
24401 insta::assert_snapshot!(text);
24402 }
24403
24404 #[test]
24405 fn snap_time_travel_input_prompt() {
24406 let mut app = new_app(ViewMode::Main, 0);
24407 app.update(key(KeyCode::Char('t')));
24408 app.update(key(KeyCode::Char('H')));
24410 app.update(key(KeyCode::Char('E')));
24411 app.update(key(KeyCode::Char('A')));
24412 app.update(key(KeyCode::Char('D')));
24413 let text = render_app(&app, 100, 30);
24414 insta::assert_snapshot!(text);
24415 }
24416
24417 #[test]
24418 fn snap_recipe_picker_overlay() {
24419 let mut app = new_app(ViewMode::Main, 0);
24420 app.update(key(KeyCode::Char('\'')));
24421 assert!(app.modal_overlay.is_some());
24422 let text = render_app(&app, 100, 30);
24423 insta::assert_snapshot!(text);
24424 }
24425
24426 #[test]
24427 fn snap_label_picker_overlay() {
24428 let mut app = new_app(ViewMode::Main, 0);
24429 app.update(key(KeyCode::Char('L')));
24430 assert!(app.modal_overlay.is_some());
24431 let text = render_app(&app, 100, 30);
24432 insta::assert_snapshot!(text);
24433 }
24434
24435 #[test]
24436 fn snap_time_travel_with_diff() {
24437 let mut app = new_app(ViewMode::Main, 0);
24438 app.mode = ViewMode::TimeTravelDiff;
24439 app.time_travel_input_active = false;
24440 app.time_travel_last_ref = Some("HEAD~1".to_string());
24441 app.time_travel_diff = Some(crate::analysis::diff::compare_snapshots(
24442 &[],
24443 &app.analyzer.issues,
24444 ));
24445 let text = render_app(&app, 100, 30);
24446 insta::assert_snapshot!(text);
24447 }
24448
24449 #[test]
24452 fn tree_view_empty_issues_no_panic() {
24453 let mut app = new_app_with_issues(ViewMode::Main, 0, vec![]);
24454 app.update(key(KeyCode::Char('T')));
24455 let list = app.list_panel_text();
24456 assert!(!list.is_empty());
24457 app.update(key(KeyCode::Char('j')));
24458 app.update(key(KeyCode::Char('k')));
24459 app.update(key(KeyCode::Tab));
24460 let detail = app.detail_panel_text();
24461 assert!(!detail.is_empty());
24462 }
24463
24464 #[test]
24465 fn label_dashboard_empty_issues_no_panic() {
24466 let mut app = new_app_with_issues(ViewMode::Main, 0, vec![]);
24467 app.update(key(KeyCode::Char('[')));
24468 let list = app.list_panel_text();
24469 assert!(!list.is_empty());
24470 app.update(key(KeyCode::Char('j')));
24471 app.update(key(KeyCode::Char('k')));
24472 app.update(key(KeyCode::Tab));
24473 let detail = app.detail_panel_text();
24474 assert!(!detail.is_empty());
24475 }
24476
24477 #[test]
24478 fn flow_matrix_empty_issues_no_panic() {
24479 let mut app = new_app_with_issues(ViewMode::Main, 0, vec![]);
24480 app.update(key(KeyCode::Char(']')));
24481 let list = app.list_panel_text();
24482 assert!(!list.is_empty());
24483 app.update(key(KeyCode::Char('j')));
24484 app.update(key(KeyCode::Char('k')));
24485 app.update(key(KeyCode::Tab));
24486 let detail = app.detail_panel_text();
24487 assert!(!detail.is_empty());
24488 }
24489
24490 #[test]
24491 fn sprint_empty_issues_no_panic() {
24492 let mut app = new_app_with_issues(ViewMode::Main, 0, vec![]);
24493 app.mode = ViewMode::Sprint;
24494 app.sprint_data = vec![make_sprint("s1", "Sprint Empty", vec!["X"])];
24495 let list = app.list_panel_text();
24496 assert!(!list.is_empty());
24497 app.update(key(KeyCode::Char('j')));
24498 app.update(key(KeyCode::Char('k')));
24499 let detail = app.detail_panel_text();
24500 assert!(!detail.is_empty());
24501 }
24502
24503 #[test]
24504 fn time_travel_render_all_states_no_panic() {
24505 let mut app = new_app(ViewMode::Main, 0);
24506 app.mode = ViewMode::TimeTravelDiff;
24508 app.time_travel_input_active = true;
24509 let _ = render_app(&app, 100, 30);
24510
24511 app.time_travel_input_active = false;
24513 let _ = render_app(&app, 100, 30);
24514
24515 app.time_travel_diff = Some(crate::analysis::diff::compare_snapshots(
24517 &[],
24518 &app.analyzer.issues,
24519 ));
24520 let _ = render_app(&app, 100, 30);
24521 }
24522
24523 #[test]
24524 fn actionable_empty_issues_no_panic() {
24525 let mut app = new_app_with_issues(ViewMode::Main, 0, vec![]);
24526 app.update(key(KeyCode::Char('a')));
24527 let list = app.list_panel_text();
24528 assert!(!list.is_empty());
24529 app.update(key(KeyCode::Char('j')));
24530 app.update(key(KeyCode::Char('k')));
24531 let _ = render_app(&app, 100, 30);
24532 }
24533
24534 #[test]
24535 fn all_modes_render_at_narrow_width_no_panic() {
24536 for mode in [
24537 ViewMode::Main,
24538 ViewMode::Board,
24539 ViewMode::Insights,
24540 ViewMode::Graph,
24541 ViewMode::History,
24542 ViewMode::Actionable,
24543 ViewMode::Attention,
24544 ViewMode::Tree,
24545 ViewMode::LabelDashboard,
24546 ViewMode::FlowMatrix,
24547 ViewMode::Sprint,
24548 ViewMode::TimeTravelDiff,
24549 ] {
24550 let _ = render_frame(mode, 40, 10);
24551 }
24552 }
24553
24554 #[test]
24555 fn all_modes_render_with_single_issue_no_panic() {
24556 let single = vec![Issue {
24557 id: "X".to_string(),
24558 title: "Solo".to_string(),
24559 status: "open".to_string(),
24560 issue_type: "task".to_string(),
24561 ..Issue::default()
24562 }];
24563 for mode in [
24564 ViewMode::Main,
24565 ViewMode::Board,
24566 ViewMode::Insights,
24567 ViewMode::Graph,
24568 ViewMode::Actionable,
24569 ViewMode::Attention,
24570 ViewMode::Tree,
24571 ViewMode::LabelDashboard,
24572 ViewMode::FlowMatrix,
24573 ] {
24574 let app = new_app_with_issues(mode, 0, single.clone());
24575 let _ = render_app(&app, 100, 30);
24576 }
24577 }
24578
24579 #[test]
24580 fn all_modes_render_with_all_closed_issues_no_panic() {
24581 let closed = vec![
24582 Issue {
24583 id: "X".to_string(),
24584 title: "Done A".to_string(),
24585 status: "closed".to_string(),
24586 ..Issue::default()
24587 },
24588 Issue {
24589 id: "Y".to_string(),
24590 title: "Done B".to_string(),
24591 status: "closed".to_string(),
24592 ..Issue::default()
24593 },
24594 ];
24595 for mode in [
24596 ViewMode::Main,
24597 ViewMode::Board,
24598 ViewMode::Insights,
24599 ViewMode::Graph,
24600 ViewMode::Actionable,
24601 ViewMode::Attention,
24602 ViewMode::Tree,
24603 ViewMode::LabelDashboard,
24604 ViewMode::FlowMatrix,
24605 ] {
24606 let app = new_app_with_issues(mode, 0, closed.clone());
24607 let _ = render_app(&app, 100, 30);
24608 }
24609 }
24610
24611 #[test]
24614 fn keyflow_full_newer_mode_tour() {
24615 let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
24616 assert_eq!(app.mode, ViewMode::Main);
24617
24618 app.update(key(KeyCode::Char('a')));
24620 assert_eq!(app.mode, ViewMode::Actionable);
24621 app.update(key(KeyCode::Char('j')));
24622 app.update(key(KeyCode::Tab));
24623 assert_eq!(app.focus, FocusPane::Detail);
24624 app.update(key(KeyCode::Char('a')));
24625 assert_eq!(app.mode, ViewMode::Main);
24626
24627 app.update(key(KeyCode::Char('!')));
24629 assert_eq!(app.mode, ViewMode::Attention);
24630 app.update(key(KeyCode::Char('j')));
24631 app.update(key(KeyCode::Char('!')));
24632 assert_eq!(app.mode, ViewMode::Main);
24633
24634 app.update(key(KeyCode::Char('T')));
24636 assert_eq!(app.mode, ViewMode::Tree);
24637 app.update(key(KeyCode::Char('j')));
24638 app.update(key(KeyCode::Tab));
24639 app.update(key(KeyCode::Char('q')));
24640 assert_eq!(app.mode, ViewMode::Main);
24641
24642 app.update(key(KeyCode::Char('[')));
24644 assert_eq!(app.mode, ViewMode::LabelDashboard);
24645 app.update(key(KeyCode::Char('j')));
24646 app.update(key(KeyCode::Char('q')));
24647 assert_eq!(app.mode, ViewMode::Main);
24648
24649 app.update(key(KeyCode::Char(']')));
24651 assert_eq!(app.mode, ViewMode::FlowMatrix);
24652 app.update(key(KeyCode::Char('j')));
24653 app.update(key(KeyCode::Char('q')));
24654 assert_eq!(app.mode, ViewMode::Main);
24655
24656 app.update(key(KeyCode::Char('t')));
24658 assert_eq!(app.mode, ViewMode::TimeTravelDiff);
24659 app.update(key(KeyCode::Escape));
24660 assert_eq!(app.mode, ViewMode::Main);
24661
24662 assert!(
24664 app.key_trace.len() >= 15,
24665 "should have many trace entries: {}",
24666 app.key_trace.len()
24667 );
24668 }
24669
24670 #[test]
24671 fn keyflow_sprint_full_journey() {
24672 let mut app = new_app(ViewMode::Main, 0);
24673
24674 app.update(key(KeyCode::Char('S')));
24676 assert_eq!(app.mode, ViewMode::Sprint);
24677
24678 app.sprint_data = vec![
24680 make_sprint("s1", "Sprint 1", vec!["A", "B"]),
24681 make_sprint("s2", "Sprint 2", vec!["C"]),
24682 ];
24683
24684 app.update(key(KeyCode::Char('j')));
24686 assert_eq!(app.sprint_cursor, 1);
24687
24688 app.update(key(KeyCode::Tab));
24690 assert_eq!(app.focus, FocusPane::Detail);
24691
24692 app.update(key(KeyCode::Tab));
24694 assert_eq!(app.focus, FocusPane::List);
24695
24696 app.update(key(KeyCode::Char('S')));
24698 assert_eq!(app.mode, ViewMode::Main);
24699 }
24700
24701 #[test]
24702 fn keyflow_modal_chain() {
24703 let mut app = new_app(ViewMode::Main, 0);
24704
24705 app.update(key(KeyCode::Char('\'')));
24707 assert!(matches!(
24708 app.modal_overlay,
24709 Some(ModalOverlay::RecipePicker { .. })
24710 ));
24711 app.update(key(KeyCode::Char('j')));
24712 app.update(key(KeyCode::Escape));
24713 assert!(app.modal_overlay.is_none());
24714
24715 app.update(key(KeyCode::Char('L')));
24717 assert!(matches!(
24718 app.modal_overlay,
24719 Some(ModalOverlay::LabelPicker { .. })
24720 ));
24721 app.update(key(KeyCode::Enter));
24722 assert!(app.modal_overlay.is_none());
24723 assert!(app.modal_label_filter.is_some());
24724
24725 app.set_list_filter(ListFilter::All);
24727 assert!(app.modal_label_filter.is_none());
24728 }
24729
24730 #[test]
24731 fn keyflow_rapid_mode_switching_no_panic() {
24732 let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
24733 let keys = [
24735 KeyCode::Char('b'), KeyCode::Char('q'), KeyCode::Char('i'), KeyCode::Char('q'), KeyCode::Char('g'), KeyCode::Char('q'), KeyCode::Char('a'), KeyCode::Char('a'), KeyCode::Char('!'), KeyCode::Char('!'), KeyCode::Char('T'), KeyCode::Char('q'), KeyCode::Char('['), KeyCode::Char('q'), KeyCode::Char(']'), KeyCode::Char('q'), KeyCode::Char('t'), KeyCode::Escape, KeyCode::Char('b'), KeyCode::Char('g'), KeyCode::Char('q'), ];
24757 for k in keys {
24758 app.update(key(k));
24759 }
24760 assert!(!matches!(app.mode, ViewMode::TimeTravelDiff));
24762 }
24763
24764 #[test]
24765 fn keyflow_navigation_in_all_newer_modes() {
24766 let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
24767 for mode_key in [
24769 KeyCode::Char('a'), KeyCode::Char('!'), KeyCode::Char('T'), KeyCode::Char('['), KeyCode::Char(']'), ] {
24775 app.update(key(mode_key));
24776 app.update(key(KeyCode::Char('j')));
24777 app.update(key(KeyCode::Char('j')));
24778 app.update(key(KeyCode::Char('k')));
24779 app.update(key(KeyCode::Tab));
24780 app.update(key(KeyCode::Char('j')));
24781 app.update(key(KeyCode::Tab));
24782 let exit_key = match mode_key {
24784 KeyCode::Char('a') => KeyCode::Char('a'),
24785 KeyCode::Char('!') => KeyCode::Char('!'),
24786 _ => KeyCode::Char('q'),
24787 };
24788 app.update(key(exit_key));
24789 assert_eq!(
24790 app.mode,
24791 ViewMode::Main,
24792 "should return to Main from mode entered via {:?}",
24793 mode_key
24794 );
24795 }
24796 }
24797
24798 #[test]
24799 fn keyflow_help_from_newer_modes() {
24800 let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
24801 for mode_key in [
24803 KeyCode::Char('a'),
24804 KeyCode::Char('T'),
24805 KeyCode::Char('['),
24806 KeyCode::Char(']'),
24807 ] {
24808 app.update(key(mode_key));
24809 app.update(key(KeyCode::Char('?')));
24810 assert!(app.show_help, "help should open in mode {:?}", mode_key);
24811 app.update(key(KeyCode::Char('?')));
24812 assert!(!app.show_help);
24813 let exit = match mode_key {
24815 KeyCode::Char('a') => KeyCode::Char('a'),
24816 _ => KeyCode::Char('q'),
24817 };
24818 app.update(key(exit));
24819 }
24820 }
24821
24822 #[test]
24823 fn keyflow_filter_then_mode_switch_preserves_filter() {
24824 let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
24825 app.update(key(KeyCode::Char('o')));
24827 assert_eq!(app.list_filter, ListFilter::Open);
24828
24829 app.update(key(KeyCode::Char('T')));
24831 assert_eq!(app.mode, ViewMode::Tree);
24832 app.update(key(KeyCode::Char('q')));
24833 assert_eq!(app.mode, ViewMode::Main);
24834 assert_eq!(
24835 app.list_filter,
24836 ListFilter::Open,
24837 "filter should persist across mode transitions"
24838 );
24839 }
24840
24841 #[test]
24844 fn graph_with_cycle_issues_no_panic() {
24845 let issues = vec![
24846 Issue {
24847 id: "X".to_string(),
24848 title: "Cyclic A".to_string(),
24849 status: "open".to_string(),
24850 issue_type: "task".to_string(),
24851 dependencies: vec![Dependency {
24852 issue_id: "X".to_string(),
24853 depends_on_id: "Y".to_string(),
24854 dep_type: "blocks".to_string(),
24855 ..Dependency::default()
24856 }],
24857 ..Issue::default()
24858 },
24859 Issue {
24860 id: "Y".to_string(),
24861 title: "Cyclic B".to_string(),
24862 status: "open".to_string(),
24863 issue_type: "task".to_string(),
24864 dependencies: vec![Dependency {
24865 issue_id: "Y".to_string(),
24866 depends_on_id: "X".to_string(),
24867 dep_type: "blocks".to_string(),
24868 ..Dependency::default()
24869 }],
24870 ..Issue::default()
24871 },
24872 ];
24873 for mode in [
24874 ViewMode::Main,
24875 ViewMode::Graph,
24876 ViewMode::Insights,
24877 ViewMode::Actionable,
24878 ] {
24879 let app = new_app_with_issues(mode, 0, issues.clone());
24880 let _ = render_app(&app, 100, 30);
24881 }
24882 }
24883
24884 #[test]
24885 fn attention_detail_shows_correct_label_on_navigation() {
24886 let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
24887 app.update(key(KeyCode::Char('!')));
24888 let labels_count = app.attention_result.as_ref().unwrap().labels.len();
24889 if labels_count >= 2 {
24890 let detail_0 = app.detail_panel_text();
24891 app.update(key(KeyCode::Char('j')));
24892 let detail_1 = app.detail_panel_text();
24893 assert_ne!(
24895 detail_0, detail_1,
24896 "navigating to next label should change detail"
24897 );
24898 }
24899 }
24900
24901 #[test]
24902 fn sprint_tab_switches_focus_and_navigation_context() {
24903 let mut app = new_app(ViewMode::Main, 0);
24904 app.mode = ViewMode::Sprint;
24905 app.sprint_data = vec![
24906 make_sprint("s1", "Sprint 1", vec!["A", "B"]),
24907 make_sprint("s2", "Sprint 2", vec!["C"]),
24908 ];
24909
24910 app.focus = FocusPane::List;
24912 app.update(key(KeyCode::Char('j')));
24913 assert_eq!(app.sprint_cursor, 1);
24914
24915 app.update(key(KeyCode::Tab));
24917 assert_eq!(app.focus, FocusPane::Detail);
24918
24919 app.update(key(KeyCode::Char('j')));
24921 app.update(key(KeyCode::Tab));
24925 assert_eq!(app.focus, FocusPane::List);
24926 }
24927
24928 #[test]
24929 fn modal_overlay_blocks_mode_switch_keys() {
24930 let mut app = new_app(ViewMode::Main, 0);
24931 app.update(key(KeyCode::Char('\'')));
24932 assert!(app.modal_overlay.is_some());
24933
24934 app.update(key(KeyCode::Char('b')));
24936 assert_eq!(
24937 app.mode,
24938 ViewMode::Main,
24939 "b should not switch mode during modal"
24940 );
24941 assert!(app.modal_overlay.is_some(), "modal should still be open");
24942
24943 app.update(key(KeyCode::Char('g')));
24944 assert_eq!(app.mode, ViewMode::Main);
24945
24946 app.update(key(KeyCode::Escape));
24948 assert!(app.modal_overlay.is_none());
24949
24950 app.update(key(KeyCode::Char('b')));
24952 assert_eq!(app.mode, ViewMode::Board);
24953 }
24954
24955 #[test]
24956 fn label_filter_affects_visible_issues() {
24957 let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
24958 let total = app.visible_issue_indices().len();
24959 assert!(total >= 3);
24960
24961 app.modal_label_filter = Some("frontend".to_string());
24963 let filtered = app.visible_issue_indices().len();
24964 assert!(
24965 filtered < total,
24966 "label filter should reduce visible issues: {filtered} < {total}"
24967 );
24968 }
24969
24970 #[test]
24971 fn label_filter_matches_case_insensitively() {
24972 let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
24973 app.modal_label_filter = Some("FRONTEND".to_string());
24974
24975 let visible_ids = app
24976 .visible_issue_indices()
24977 .into_iter()
24978 .map(|index| app.analyzer.issues[index].id.as_str())
24979 .collect::<Vec<_>>();
24980
24981 assert_eq!(visible_ids, vec!["C"]);
24982 }
24983
24984 #[test]
24985 fn set_label_filter_toggles_case_insensitively() {
24986 let mut app = new_app(ViewMode::Main, 0);
24987 app.modal_label_filter = Some("backend".to_string());
24988
24989 app.set_label_filter("BACKEND");
24990
24991 assert!(app.modal_label_filter.is_none());
24992 assert_eq!(app.status_msg, "Label filter cleared");
24993 }
24994
24995 #[test]
24996 fn repo_filter_affects_visible_issues() {
24997 let mut app = new_app(ViewMode::Main, 0);
24998 app.modal_repo_filter = Some("viewer".to_string());
25000 let filtered = app.visible_issue_indices().len();
25001 assert!(filtered >= 1, "repo filter should show at least one issue");
25002 }
25003
25004 #[test]
25005 fn header_shows_combined_filters() {
25006 let mut app = new_app(ViewMode::Main, 0);
25007 app.modal_label_filter = Some("core".to_string());
25008 app.modal_repo_filter = Some("viewer".to_string());
25009 let text = render_app(&app, 100, 3);
25010 assert!(
25011 text.contains("label:core") || text.contains("core"),
25012 "header should mention label filter: {text}"
25013 );
25014 }
25015
25016 #[test]
25019 fn slow_metrics_pending_flag_default_false() {
25020 let app = new_app(ViewMode::Main, 0);
25021 assert!(
25022 !app.slow_metrics_pending,
25023 "small graph should not have pending slow metrics"
25024 );
25025 }
25026
25027 #[test]
25028 fn slow_metrics_pending_shows_in_header() {
25029 let mut app = new_app(ViewMode::Main, 0);
25030 app.slow_metrics_pending = true;
25031 let text = render_app(&app, 120, 3);
25032 assert!(
25033 text.contains("computing"),
25034 "header should show metrics computing indicator: {text}"
25035 );
25036 }
25037
25038 #[test]
25039 fn slow_metrics_pending_clears_after_apply() {
25040 let mut app = new_app(ViewMode::Main, 0);
25041 app.slow_metrics_pending = true;
25042 let slow = app
25043 .analyzer
25044 .graph
25045 .compute_metrics_with_config(&crate::analysis::graph::AnalysisConfig::slow_phase());
25046 app.analyzer.apply_slow_metrics(slow);
25047 app.slow_metrics_pending = false;
25048 let text = render_app(&app, 120, 3);
25049 assert!(
25050 !text.contains("computing"),
25051 "header should not show computing after metrics applied: {text}"
25052 );
25053 }
25054
25055 #[test]
25058 fn keyflow_tree_full_journey() {
25059 let mut app = new_app(ViewMode::Main, 0);
25060 assert_eq!(app.mode, ViewMode::Main);
25061
25062 app.update(key(KeyCode::Char('T')));
25064 assert_eq!(app.mode, ViewMode::Tree);
25065 assert!(!app.tree_flat_nodes.is_empty(), "tree should build nodes");
25066 assert_eq!(app.focus, FocusPane::List);
25067
25068 let list = app.list_panel_text();
25070 assert!(
25071 list.contains("Dependency tree") || list.contains("no dependency tree"),
25072 "list should show tree header: {list}"
25073 );
25074
25075 let node_count = app.tree_flat_nodes.len();
25077 if node_count > 1 {
25078 app.update(key(KeyCode::Char('j')));
25079 assert_eq!(app.tree_cursor, 1);
25080 app.update(key(KeyCode::Char('k')));
25081 assert_eq!(app.tree_cursor, 0);
25082 }
25083
25084 app.update(key(KeyCode::Tab));
25086 assert_eq!(app.focus, FocusPane::Detail);
25087 let detail = app.detail_panel_text();
25088 assert!(
25089 detail.contains("ID:"),
25090 "detail should show issue ID: {detail}"
25091 );
25092
25093 app.update(key(KeyCode::Tab));
25095 assert_eq!(app.focus, FocusPane::List);
25096
25097 app.update(key(KeyCode::Char('?')));
25099 assert!(app.show_help);
25100 let help_text = render_app(&app, 100, 30);
25101 assert!(
25102 help_text.contains("Help") || help_text.contains("help"),
25103 "help overlay should render: {help_text}"
25104 );
25105 app.update(key(KeyCode::Char('?')));
25106 assert!(!app.show_help);
25107
25108 app.update(key(KeyCode::Char('T')));
25110 assert_eq!(app.mode, ViewMode::Main);
25111 }
25112
25113 #[test]
25114 fn tree_narrow_width_rendering_no_panic() {
25115 let mut app = new_app(ViewMode::Main, 0);
25116 app.update(key(KeyCode::Char('T')));
25117 assert_eq!(app.mode, ViewMode::Tree);
25118
25119 let text_40 = render_app(&app, 40, 20);
25121 assert!(!text_40.is_empty(), "40x20 render should produce output");
25122
25123 let text_20 = render_app(&app, 20, 10);
25125 assert!(!text_20.is_empty(), "20x10 render should produce output");
25126
25127 app.update(key(KeyCode::Char('j')));
25129 app.update(key(KeyCode::Tab));
25130 let text_detail = render_app(&app, 40, 20);
25131 assert!(
25132 !text_detail.is_empty(),
25133 "detail at 40x20 should produce output"
25134 );
25135 }
25136
25137 #[test]
25138 fn tree_cursor_clamp_at_boundary() {
25139 let mut app = new_app(ViewMode::Main, 0);
25140 app.update(key(KeyCode::Char('T')));
25141 assert_eq!(app.mode, ViewMode::Tree);
25142
25143 app.update(key(KeyCode::Char('k')));
25145 assert_eq!(app.tree_cursor, 0, "cursor should clamp at top");
25146
25147 let node_count = app.tree_flat_nodes.len();
25149 for _ in 0..node_count + 5 {
25150 app.update(key(KeyCode::Char('j')));
25151 }
25152 assert_eq!(
25153 app.tree_cursor,
25154 node_count.saturating_sub(1),
25155 "cursor should clamp at bottom"
25156 );
25157
25158 app.update(key(KeyCode::Char('j')));
25160 assert_eq!(
25161 app.tree_cursor,
25162 node_count.saturating_sub(1),
25163 "cursor should stay clamped at bottom"
25164 );
25165 }
25166
25167 #[test]
25170 fn keyflow_label_dashboard_full_journey() {
25171 let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
25172 assert_eq!(app.mode, ViewMode::Main);
25173
25174 app.update(key(KeyCode::Char('[')));
25176 assert_eq!(app.mode, ViewMode::LabelDashboard);
25177 assert!(app.label_dashboard.is_some());
25178 assert_eq!(app.focus, FocusPane::List);
25179
25180 let list = app.list_panel_text();
25182 assert!(
25183 list.contains("Label health") || list.contains("no labels"),
25184 "list should show label health header: {list}"
25185 );
25186
25187 let count = app.label_dashboard.as_ref().map_or(0, |r| r.labels.len());
25189 if count > 1 {
25190 app.update(key(KeyCode::Char('j')));
25191 assert_eq!(app.label_dashboard_cursor, 1);
25192 }
25193
25194 app.update(key(KeyCode::Tab));
25196 assert_eq!(app.focus, FocusPane::Detail);
25197
25198 let detail = app.detail_panel_text();
25200 if count > 0 {
25201 assert!(
25202 detail.contains("Label:") || detail.contains("Health:"),
25203 "detail should show label health info: {detail}"
25204 );
25205 }
25206
25207 app.update(key(KeyCode::Char('k')));
25209 if count > 1 {
25210 assert_eq!(app.label_dashboard_cursor, 0);
25211 }
25212
25213 app.update(key(KeyCode::Tab));
25215 assert_eq!(app.focus, FocusPane::List);
25216
25217 app.update(key(KeyCode::Char('?')));
25219 assert!(app.show_help);
25220 app.update(key(KeyCode::Char('?')));
25221 assert!(!app.show_help);
25222
25223 app.update(key(KeyCode::Char('[')));
25225 assert_eq!(app.mode, ViewMode::Main);
25226 }
25227
25228 #[test]
25229 fn label_dashboard_narrow_width_rendering_no_panic() {
25230 let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
25231 app.update(key(KeyCode::Char('[')));
25232 assert_eq!(app.mode, ViewMode::LabelDashboard);
25233
25234 let text = render_app(&app, 40, 20);
25235 assert!(
25236 !text.is_empty(),
25237 "narrow label dashboard should produce output"
25238 );
25239
25240 let text_tiny = render_app(&app, 20, 10);
25241 assert!(
25242 !text_tiny.is_empty(),
25243 "very narrow label dashboard should produce output"
25244 );
25245 }
25246
25247 #[test]
25248 fn label_dashboard_detail_changes_on_navigation() {
25249 let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
25250 app.update(key(KeyCode::Char('[')));
25251
25252 let count = app.label_dashboard.as_ref().map_or(0, |r| r.labels.len());
25253 if count >= 2 {
25254 let detail_0 = app.detail_panel_text();
25255 app.update(key(KeyCode::Char('j')));
25256 let detail_1 = app.detail_panel_text();
25257 assert_ne!(
25258 detail_0, detail_1,
25259 "navigating labels should change detail content"
25260 );
25261 }
25262 }
25263
25264 #[test]
25267 fn sprint_narrow_width_rendering_no_panic() {
25268 let mut app = new_app(ViewMode::Main, 0);
25269 app.mode = ViewMode::Sprint;
25270 app.sprint_data = vec![
25271 make_sprint("s1", "Sprint Alpha", vec!["A", "B"]),
25272 make_sprint("s2", "Sprint Beta", vec!["C"]),
25273 ];
25274
25275 let text = render_app(&app, 40, 20);
25276 assert!(
25277 !text.is_empty(),
25278 "narrow sprint render should produce output"
25279 );
25280
25281 let text_tiny = render_app(&app, 20, 10);
25282 assert!(
25283 !text_tiny.is_empty(),
25284 "very narrow sprint render should produce output"
25285 );
25286 }
25287
25288 #[test]
25289 fn sprint_detail_focus_navigation_no_panic() {
25290 let mut app = new_app(ViewMode::Main, 0);
25291 app.mode = ViewMode::Sprint;
25292 app.sprint_data = vec![
25293 make_sprint("s1", "Sprint Alpha", vec!["A", "B", "C"]),
25294 make_sprint("s2", "Sprint Beta", vec!["D"]),
25295 ];
25296
25297 app.update(key(KeyCode::Char('j')));
25299 assert_eq!(app.sprint_cursor, 1);
25300
25301 app.update(key(KeyCode::Tab));
25303 assert_eq!(app.focus, FocusPane::Detail);
25304 app.update(key(KeyCode::Char('j')));
25305 app.update(key(KeyCode::Char('k')));
25306
25307 app.update(key(KeyCode::Tab));
25309 app.update(key(KeyCode::Char('k')));
25310 assert_eq!(app.sprint_cursor, 0);
25311
25312 for width in [40, 80, 120] {
25314 let _ = render_app(&app, width, 30);
25315 }
25316 }
25317
25318 #[test]
25321 fn keyflow_actionable_full_journey() {
25322 let mut app = new_app(ViewMode::Main, 0);
25323 assert_eq!(app.mode, ViewMode::Main);
25324
25325 app.update(key(KeyCode::Char('a')));
25327 assert_eq!(app.mode, ViewMode::Actionable);
25328 assert!(app.actionable_plan.is_some());
25329 assert_eq!(app.focus, FocusPane::List);
25330
25331 let list = app.list_panel_text();
25333 assert!(
25334 list.contains("ACTIONABLE ITEMS"),
25335 "list should show actionable header: {list}"
25336 );
25337 assert!(
25338 list.contains("TRACK"),
25339 "list should show track info: {list}"
25340 );
25341
25342 let track_count = app.actionable_plan.as_ref().map_or(0, |p| p.tracks.len());
25344 if track_count > 1 {
25345 app.update(key(KeyCode::Char('j')));
25346 assert_eq!(app.actionable_track_cursor, 1);
25347 app.update(key(KeyCode::Char('k')));
25348 assert_eq!(app.actionable_track_cursor, 0);
25349 }
25350
25351 app.update(key(KeyCode::Tab));
25353 assert_eq!(app.focus, FocusPane::Detail);
25354 let detail = app.detail_panel_text();
25355 assert!(
25356 detail.contains("TRACK"),
25357 "detail should show track info: {detail}"
25358 );
25359
25360 let item_count = app
25362 .actionable_plan
25363 .as_ref()
25364 .and_then(|p| p.tracks.first())
25365 .map_or(0, |t| t.items.len());
25366 if item_count > 1 {
25367 app.update(key(KeyCode::Char('j')));
25368 assert_eq!(app.actionable_item_cursor, 1);
25369 app.update(key(KeyCode::Char('k')));
25370 assert_eq!(app.actionable_item_cursor, 0);
25371 }
25372
25373 app.update(key(KeyCode::Char('?')));
25375 assert!(app.show_help);
25376 let help_text = render_app(&app, 100, 30);
25377 assert!(
25378 help_text.contains("Help") || help_text.contains("help"),
25379 "help overlay should render"
25380 );
25381 app.update(key(KeyCode::Char('?')));
25382 assert!(!app.show_help);
25383
25384 app.update(key(KeyCode::Tab));
25386 assert_eq!(app.focus, FocusPane::List);
25387 app.update(key(KeyCode::Char('a')));
25388 assert_eq!(app.mode, ViewMode::Main);
25389 }
25390
25391 #[test]
25392 fn actionable_narrow_width_rendering_no_panic() {
25393 let mut app = new_app(ViewMode::Main, 0);
25394 app.update(key(KeyCode::Char('a')));
25395 assert_eq!(app.mode, ViewMode::Actionable);
25396
25397 let text_40 = render_app(&app, 40, 20);
25399 assert!(!text_40.is_empty(), "40x20 render should produce output");
25400
25401 let text_20 = render_app(&app, 20, 10);
25403 assert!(!text_20.is_empty(), "20x10 render should produce output");
25404
25405 app.update(key(KeyCode::Char('j')));
25407 app.update(key(KeyCode::Tab));
25408 let text_detail = render_app(&app, 40, 20);
25409 assert!(
25410 !text_detail.is_empty(),
25411 "detail at 40x20 should produce output"
25412 );
25413 }
25414
25415 #[test]
25416 fn actionable_track_cursor_clamp_at_boundary() {
25417 let mut app = new_app(ViewMode::Main, 0);
25418 app.update(key(KeyCode::Char('a')));
25419 assert_eq!(app.mode, ViewMode::Actionable);
25420
25421 app.update(key(KeyCode::Char('k')));
25423 assert_eq!(
25424 app.actionable_track_cursor, 0,
25425 "track cursor should clamp at top"
25426 );
25427
25428 let track_count = app.actionable_plan.as_ref().map_or(0, |p| p.tracks.len());
25430 for _ in 0..track_count + 5 {
25431 app.update(key(KeyCode::Char('j')));
25432 }
25433 assert_eq!(
25434 app.actionable_track_cursor,
25435 track_count.saturating_sub(1),
25436 "track cursor should clamp at bottom"
25437 );
25438 }
25439
25440 #[test]
25441 fn actionable_detail_changes_on_track_navigation() {
25442 let mut app = new_app(ViewMode::Main, 0);
25443 app.update(key(KeyCode::Char('a')));
25444 assert_eq!(app.mode, ViewMode::Actionable);
25445
25446 let track_count = app.actionable_plan.as_ref().map_or(0, |p| p.tracks.len());
25447 if track_count > 1 {
25448 let detail_first = app.detail_panel_text();
25449
25450 app.update(key(KeyCode::Char('j')));
25451 let detail_second = app.detail_panel_text();
25452
25453 assert_ne!(
25454 detail_first, detail_second,
25455 "detail should change when navigating to a different track"
25456 );
25457 }
25458 }
25459
25460 #[test]
25461 fn actionable_item_cursor_resets_on_track_change() {
25462 let mut app = new_app(ViewMode::Main, 0);
25463 app.update(key(KeyCode::Char('a')));
25464
25465 app.update(key(KeyCode::Tab));
25467 app.update(key(KeyCode::Char('j')));
25468 let item_pos = app.actionable_item_cursor;
25469
25470 app.update(key(KeyCode::Tab));
25472 let track_count = app.actionable_plan.as_ref().map_or(0, |p| p.tracks.len());
25473 if track_count > 1 && item_pos > 0 {
25474 app.update(key(KeyCode::Char('j')));
25475 assert_eq!(
25476 app.actionable_item_cursor, 0,
25477 "item cursor should reset when changing track"
25478 );
25479 }
25480 }
25481
25482 #[test]
25483 fn actionable_detail_scroll_resets_on_track_navigation() {
25484 let mut app = new_app(ViewMode::Actionable, 0);
25485 app.mode = ViewMode::Actionable;
25486 app.focus = FocusPane::List;
25487 app.actionable_plan = Some(crate::analysis::plan::ExecutionPlan {
25488 total_actionable: 2,
25489 total_blocked: 0,
25490 tracks: vec![
25491 crate::analysis::plan::ExecutionTrack {
25492 id: "track-1".to_string(),
25493 reason: "first".to_string(),
25494 items: vec![crate::analysis::plan::ExecutionItem {
25495 id: "A".to_string(),
25496 title: "Alpha".to_string(),
25497 status: "open".to_string(),
25498 priority: 1,
25499 score: 5.0,
25500 unblocks: Vec::new(),
25501 claim_command: "br update A --status=in_progress".to_string(),
25502 show_command: "br show A".to_string(),
25503 }],
25504 },
25505 crate::analysis::plan::ExecutionTrack {
25506 id: "track-2".to_string(),
25507 reason: "second".to_string(),
25508 items: vec![crate::analysis::plan::ExecutionItem {
25509 id: "B".to_string(),
25510 title: "Beta".to_string(),
25511 status: "open".to_string(),
25512 priority: 1,
25513 score: 4.0,
25514 unblocks: Vec::new(),
25515 claim_command: "br update B --status=in_progress".to_string(),
25516 show_command: "br show B".to_string(),
25517 }],
25518 },
25519 ],
25520 summary: crate::analysis::plan::PlanSummary {
25521 track_count: 2,
25522 actionable_count: 2,
25523 unblocks_count: Some(0),
25524 highest_impact: Some("A".to_string()),
25525 impact_reason: Some("highest impact: A (score 5.00)".to_string()),
25526 },
25527 });
25528 app.detail_scroll_offset = 7;
25529
25530 app.move_actionable_cursor(1);
25531
25532 assert_eq!(app.actionable_track_cursor, 1);
25533 assert_eq!(app.actionable_item_cursor, 0);
25534 assert_eq!(app.detail_scroll_offset, 0);
25535 }
25536
25537 #[test]
25538 fn actionable_detail_scroll_resets_on_item_navigation() {
25539 let mut app = new_app(ViewMode::Actionable, 0);
25540 app.mode = ViewMode::Actionable;
25541 app.focus = FocusPane::Detail;
25542 app.actionable_plan = Some(crate::analysis::plan::ExecutionPlan {
25543 total_actionable: 2,
25544 total_blocked: 0,
25545 tracks: vec![crate::analysis::plan::ExecutionTrack {
25546 id: "track-1".to_string(),
25547 reason: "first".to_string(),
25548 items: vec![
25549 crate::analysis::plan::ExecutionItem {
25550 id: "A".to_string(),
25551 title: "Alpha".to_string(),
25552 status: "open".to_string(),
25553 priority: 1,
25554 score: 5.0,
25555 unblocks: Vec::new(),
25556 claim_command: "br update A --status=in_progress".to_string(),
25557 show_command: "br show A".to_string(),
25558 },
25559 crate::analysis::plan::ExecutionItem {
25560 id: "B".to_string(),
25561 title: "Beta".to_string(),
25562 status: "open".to_string(),
25563 priority: 1,
25564 score: 4.0,
25565 unblocks: Vec::new(),
25566 claim_command: "br update B --status=in_progress".to_string(),
25567 show_command: "br show B".to_string(),
25568 },
25569 ],
25570 }],
25571 summary: crate::analysis::plan::PlanSummary {
25572 track_count: 1,
25573 actionable_count: 2,
25574 unblocks_count: Some(0),
25575 highest_impact: Some("A".to_string()),
25576 impact_reason: Some("highest impact: A (score 5.00)".to_string()),
25577 },
25578 });
25579 app.detail_scroll_offset = 6;
25580
25581 app.move_actionable_cursor(1);
25582
25583 assert_eq!(app.actionable_item_cursor, 1);
25584 assert_eq!(app.detail_scroll_offset, 0);
25585 }
25586
25587 #[test]
25588 fn actionable_mode_entry_resets_detail_scroll_offset() {
25589 let mut app = new_app(ViewMode::Main, 0);
25590 app.detail_scroll_offset = 5;
25591
25592 app.update(key(KeyCode::Char('a')));
25593
25594 assert_eq!(app.mode, ViewMode::Actionable);
25595 assert_eq!(app.detail_scroll_offset, 0);
25596 }
25597
25598 #[test]
25599 fn actionable_mode_exit_resets_detail_scroll_offset() {
25600 let mut app = new_app(ViewMode::Main, 0);
25601 app.update(key(KeyCode::Char('a')));
25602 app.detail_scroll_offset = 5;
25603
25604 app.update(key(KeyCode::Char('a')));
25605
25606 assert_eq!(app.mode, ViewMode::Main);
25607 assert_eq!(app.detail_scroll_offset, 0);
25608 }
25609
25610 #[test]
25613 fn keyflow_attention_full_journey() {
25614 let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
25615 assert_eq!(app.mode, ViewMode::Main);
25616
25617 app.update(key(KeyCode::Char('!')));
25619 assert_eq!(app.mode, ViewMode::Attention);
25620 assert!(app.attention_result.is_some());
25621 assert_eq!(app.focus, FocusPane::List);
25622
25623 let list = app.list_panel_text();
25625 assert!(list.contains("Rank"), "list should show rank header");
25626 assert!(list.contains("Score"), "list should show score header");
25627
25628 let label_count = app.attention_result.as_ref().unwrap().labels.len();
25630 assert!(label_count >= 2);
25631 app.update(key(KeyCode::Char('j')));
25632 assert_eq!(app.attention_cursor, 1);
25633
25634 app.update(key(KeyCode::Tab));
25636 assert_eq!(app.focus, FocusPane::Detail);
25637
25638 let detail = app.detail_panel_text();
25640 assert!(detail.contains("Attention Score:"));
25641 assert!(detail.contains("Breakdown:"));
25642 assert!(detail.contains("Factors:"));
25643
25644 app.update(key(KeyCode::Char('k')));
25646 assert_eq!(app.attention_cursor, 0);
25647
25648 app.update(key(KeyCode::Tab));
25650 assert_eq!(app.focus, FocusPane::List);
25651
25652 app.update(key(KeyCode::Char('?')));
25654 assert!(app.show_help);
25655 app.update(key(KeyCode::Char('?')));
25656 assert!(!app.show_help);
25657
25658 app.update(key(KeyCode::Char('!')));
25660 assert_eq!(app.mode, ViewMode::Main);
25661 }
25662
25663 #[test]
25664 fn attention_narrow_width_rendering_no_panic() {
25665 let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
25666 app.update(key(KeyCode::Char('!')));
25667 assert_eq!(app.mode, ViewMode::Attention);
25668
25669 let text = render_app(&app, 40, 20);
25671 assert!(
25672 !text.is_empty(),
25673 "narrow attention render should produce output"
25674 );
25675
25676 let text_tiny = render_app(&app, 20, 10);
25678 assert!(
25679 !text_tiny.is_empty(),
25680 "very narrow attention render should produce output"
25681 );
25682 }
25683
25684 #[test]
25685 fn attention_tab_focus_updates_detail_context() {
25686 let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
25687 app.update(key(KeyCode::Char('!')));
25688
25689 let detail_at_0 = app.detail_panel_text();
25691
25692 app.update(key(KeyCode::Char('j')));
25694 app.update(key(KeyCode::Tab));
25695 assert_eq!(app.focus, FocusPane::Detail);
25696 let detail_at_1 = app.detail_panel_text();
25697
25698 assert_ne!(
25700 detail_at_0, detail_at_1,
25701 "detail pane should reflect cursor position change"
25702 );
25703
25704 assert!(
25706 detail_at_1.contains("Open issues") || detail_at_1.contains("Label:"),
25707 "detail should show label info: {detail_at_1}"
25708 );
25709 }
25710
25711 #[test]
25712 fn attention_cursor_clamp_at_boundary() {
25713 let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
25714 app.update(key(KeyCode::Char('!')));
25715
25716 let label_count = app.attention_result.as_ref().unwrap().labels.len();
25717
25718 for _ in 0..label_count + 5 {
25720 app.update(key(KeyCode::Char('j')));
25721 }
25722 assert!(
25723 app.attention_cursor < label_count,
25724 "cursor should be clamped: {} < {}",
25725 app.attention_cursor,
25726 label_count
25727 );
25728
25729 for _ in 0..label_count + 5 {
25731 app.update(key(KeyCode::Char('k')));
25732 }
25733 assert_eq!(app.attention_cursor, 0, "cursor should clamp to 0");
25734 }
25735
25736 #[test]
25737 fn snap_attention_list_overview() {
25738 let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
25739 app.update(key(KeyCode::Char('!')));
25740 let text = render_app(&app, 100, 30);
25742 insta::assert_snapshot!(text);
25743 }
25744
25745 #[test]
25748 fn keyflow_flow_matrix_full_journey() {
25749 let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
25750 assert_eq!(app.mode, ViewMode::Main);
25751
25752 app.update(key(KeyCode::Char(']')));
25754 assert_eq!(app.mode, ViewMode::FlowMatrix);
25755 assert!(app.flow_matrix.is_some());
25756 assert_eq!(app.focus, FocusPane::List);
25757
25758 let list = app.list_panel_text();
25760 assert!(
25761 list.contains("Cross-label flow") || list.contains("no labels"),
25762 "list should show flow header: {list}"
25763 );
25764
25765 let label_count = app.flow_matrix.as_ref().map_or(0, |f| f.labels.len());
25767 if label_count > 1 {
25768 app.update(key(KeyCode::Char('j')));
25769 assert_eq!(app.flow_matrix_row_cursor, 1);
25770
25771 app.update(key(KeyCode::Char('l')));
25773 assert_eq!(app.flow_matrix_col_cursor, 1);
25774
25775 app.update(key(KeyCode::Tab));
25777 assert_eq!(app.focus, FocusPane::Detail);
25778
25779 let detail = app.detail_panel_text();
25781 assert!(
25782 !detail.is_empty(),
25783 "detail should have content for selected cell"
25784 );
25785
25786 app.update(key(KeyCode::Char('h')));
25788 assert_eq!(app.flow_matrix_col_cursor, 0);
25789
25790 app.update(key(KeyCode::Tab));
25792 assert_eq!(app.focus, FocusPane::List);
25793 }
25794
25795 app.update(key(KeyCode::Char('?')));
25797 assert!(app.show_help);
25798 app.update(key(KeyCode::Char('?')));
25799 assert!(!app.show_help);
25800
25801 app.update(key(KeyCode::Char(']')));
25803 assert_eq!(app.mode, ViewMode::Main);
25804 }
25805
25806 #[test]
25807 fn flow_matrix_narrow_width_rendering_no_panic() {
25808 let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
25809 app.update(key(KeyCode::Char(']')));
25810 assert_eq!(app.mode, ViewMode::FlowMatrix);
25811
25812 let text = render_app(&app, 40, 20);
25814 assert!(
25815 !text.is_empty(),
25816 "narrow flow matrix render should produce output"
25817 );
25818
25819 let text_tiny = render_app(&app, 20, 10);
25821 assert!(
25822 !text_tiny.is_empty(),
25823 "very narrow flow matrix render should produce output"
25824 );
25825 }
25826
25827 #[test]
25828 fn flow_matrix_detail_changes_on_cell_navigation() {
25829 let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
25830 app.update(key(KeyCode::Char(']')));
25831
25832 let label_count = app.flow_matrix.as_ref().map_or(0, |f| f.labels.len());
25833 if label_count >= 2 {
25834 let detail_diag = app.detail_panel_text();
25836
25837 app.update(key(KeyCode::Char('l')));
25839 let detail_off = app.detail_panel_text();
25840
25841 assert_ne!(
25842 detail_diag, detail_off,
25843 "diagonal and off-diagonal cells should show different detail"
25844 );
25845
25846 app.update(key(KeyCode::Char('j')));
25848 let detail_diag_2 = app.detail_panel_text();
25849
25850 assert_ne!(
25851 detail_diag, detail_diag_2,
25852 "different diagonal cells should show different labels"
25853 );
25854 }
25855 }
25856
25857 #[test]
25858 fn flow_matrix_cursor_clamp_at_boundary() {
25859 let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
25860 app.update(key(KeyCode::Char(']')));
25861
25862 let label_count = app.flow_matrix.as_ref().map_or(0, |f| f.labels.len());
25863 if label_count > 0 {
25864 for _ in 0..label_count + 5 {
25866 app.update(key(KeyCode::Char('j')));
25867 }
25868 assert!(
25869 app.flow_matrix_row_cursor < label_count,
25870 "row cursor should clamp: {} < {}",
25871 app.flow_matrix_row_cursor,
25872 label_count
25873 );
25874
25875 for _ in 0..label_count + 5 {
25877 app.update(key(KeyCode::Char('l')));
25878 }
25879 assert!(
25880 app.flow_matrix_col_cursor < label_count,
25881 "col cursor should clamp: {} < {}",
25882 app.flow_matrix_col_cursor,
25883 label_count
25884 );
25885
25886 for _ in 0..label_count + 5 {
25888 app.update(key(KeyCode::Char('k')));
25889 }
25890 assert_eq!(app.flow_matrix_row_cursor, 0);
25891 for _ in 0..label_count + 5 {
25892 app.update(key(KeyCode::Char('h')));
25893 }
25894 assert_eq!(app.flow_matrix_col_cursor, 0);
25895 }
25896 }
25897
25898 #[test]
25899 fn snap_flow_matrix_list_overview() {
25900 let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
25901 app.update(key(KeyCode::Char(']')));
25902 let text = render_app(&app, 100, 30);
25904 insta::assert_snapshot!(text);
25905 }
25906
25907 #[test]
25908 fn all_modes_work_with_fast_only_metrics() {
25909 let mut app = new_app(ViewMode::Main, 0);
25911 let issues = app.analyzer.issues.clone();
25912 app.analyzer = Analyzer::new_fast(issues);
25913 app.slow_metrics_pending = true;
25914
25915 for mode in [
25917 ViewMode::Main,
25918 ViewMode::Board,
25919 ViewMode::Graph,
25920 ViewMode::Insights,
25921 ViewMode::Actionable,
25922 ] {
25923 app.mode = mode;
25924 let _ = render_app(&app, 100, 30);
25925 }
25926 }
25927
25928 fn journey_capture(
25940 app: &BvrApp,
25941 width: u16,
25942 height: u16,
25943 step: &str,
25944 captures: &mut Vec<(String, String)>,
25945 ) -> String {
25946 let text = render_app(app, width, height);
25947 captures.push((step.to_string(), text.clone()));
25948 text
25949 }
25950
25951 fn journey_artifact(
25953 journey_name: &str,
25954 width: u16,
25955 height: u16,
25956 captures: &[(String, String)],
25957 ) -> String {
25958 let mut out = String::new();
25959 out.push_str(&format!(
25960 "=== E2E Journey: {journey_name} | {width}x{height} ===\n\n"
25961 ));
25962 for (i, (step, text)) in captures.iter().enumerate() {
25963 out.push_str(&format!("--- Step {}: {} ---\n{}\n\n", i + 1, step, text));
25964 }
25965 out
25966 }
25967
25968 #[test]
25969 fn e2e_journey_main_board_insights_graph_investigation() {
25970 let mut app = new_app(ViewMode::Main, 0);
25971 let (w, h) = (120, 35);
25972 let mut caps: Vec<(String, String)> = Vec::new();
25973
25974 let text = journey_capture(&app, w, h, "main_list_start", &mut caps);
25976 assert!(
25977 text.contains("mode=Main") || text.contains("Issues"),
25978 "main should show issue list: {text}"
25979 );
25980
25981 app.update(key(KeyCode::Char('j')));
25983 app.update(key(KeyCode::Tab));
25984 let text = journey_capture(&app, w, h, "main_detail_focus", &mut caps);
25985 assert!(
25986 text.contains("ID:") || text.contains("Status:"),
25987 "detail should show issue: {text}"
25988 );
25989
25990 app.update(key(KeyCode::Char('b')));
25992 assert_eq!(app.mode, ViewMode::Board);
25993 let text = journey_capture(&app, w, h, "board_entry", &mut caps);
25994 assert!(
25995 text.contains("open") || text.contains("Board"),
25996 "board should show lane content: {text}"
25997 );
25998
25999 app.update(key(KeyCode::Char('l')));
26001 app.update(key(KeyCode::Char('j')));
26002 let text = journey_capture(&app, w, h, "board_navigate", &mut caps);
26003 assert!(!text.is_empty());
26004
26005 app.update(key(KeyCode::Char('g')));
26007 assert_eq!(app.mode, ViewMode::Graph);
26008 let text = journey_capture(&app, w, h, "graph_entry_from_board", &mut caps);
26009 assert!(
26010 text.contains("Graph") || text.contains("graph") || text.contains("Dep"),
26011 "graph should show graph content: {text}"
26012 );
26013
26014 app.update(key(KeyCode::Tab));
26016 let text = journey_capture(&app, w, h, "graph_detail_focus", &mut caps);
26017 assert!(!text.is_empty());
26018
26019 app.update(key(KeyCode::Char('i')));
26021 assert_eq!(app.mode, ViewMode::Insights);
26022 let text = journey_capture(&app, w, h, "insights_entry", &mut caps);
26023 assert!(
26024 text.contains("Bottleneck") || text.contains("bottleneck") || text.contains("Insight"),
26025 "insights should show analysis: {text}"
26026 );
26027
26028 app.update(key(KeyCode::Char('s')));
26030 let text = journey_capture(&app, w, h, "insights_panel_cycle", &mut caps);
26031 assert!(!text.is_empty());
26032
26033 app.update(key(KeyCode::Escape));
26035 assert_eq!(app.mode, ViewMode::Main);
26036 let text = journey_capture(&app, w, h, "main_return", &mut caps);
26037 assert!(text.contains("mode=Main") || text.contains("Issues"));
26038
26039 let artifact = journey_artifact("main→board→graph→insights→main", w, h, &caps);
26041 insta::assert_snapshot!(artifact);
26042 }
26043
26044 #[test]
26045 fn e2e_journey_actionable_tree_attention_flow() {
26046 let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
26047 let (w, h) = (120, 35);
26048 let mut caps: Vec<(String, String)> = Vec::new();
26049
26050 journey_capture(&app, w, h, "main_start", &mut caps);
26052
26053 app.update(key(KeyCode::Char('a')));
26055 assert_eq!(app.mode, ViewMode::Actionable);
26056 let text = journey_capture(&app, w, h, "actionable_entry", &mut caps);
26057 assert!(
26058 text.contains("ACTIONABLE") || text.contains("actionable"),
26059 "should show actionable items: {text}"
26060 );
26061
26062 app.update(key(KeyCode::Tab));
26064 app.update(key(KeyCode::Char('j')));
26065 journey_capture(&app, w, h, "actionable_detail_nav", &mut caps);
26066
26067 app.update(key(KeyCode::Char('a')));
26069 assert_eq!(app.mode, ViewMode::Main);
26070 app.update(key(KeyCode::Char('T')));
26071 assert_eq!(app.mode, ViewMode::Tree);
26072 let text = journey_capture(&app, w, h, "tree_entry", &mut caps);
26073 assert!(
26074 text.contains("Dependency tree") || text.contains("no dependency tree"),
26075 "tree should show structure: {text}"
26076 );
26077
26078 let has_children = app.tree_flat_nodes.iter().any(|n| n.has_children);
26080 if has_children {
26081 let idx = app
26082 .tree_flat_nodes
26083 .iter()
26084 .position(|n| n.has_children)
26085 .unwrap();
26086 app.tree_cursor = idx;
26087 app.update(key(KeyCode::Enter)); journey_capture(&app, w, h, "tree_collapsed", &mut caps);
26089 app.update(key(KeyCode::Enter)); }
26091 journey_capture(&app, w, h, "tree_navigate", &mut caps);
26092
26093 app.update(key(KeyCode::Char('T')));
26095 assert_eq!(app.mode, ViewMode::Main);
26096 app.update(key(KeyCode::Char('!')));
26097 assert_eq!(app.mode, ViewMode::Attention);
26098 let text = journey_capture(&app, w, h, "attention_entry", &mut caps);
26099 assert!(
26100 text.contains("Rank") || text.contains("Score") || text.contains("Attention"),
26101 "attention should show ranked labels: {text}"
26102 );
26103
26104 app.update(key(KeyCode::Char('j')));
26106 app.update(key(KeyCode::Tab));
26107 journey_capture(&app, w, h, "attention_detail", &mut caps);
26108
26109 app.update(key(KeyCode::Char('!')));
26111 assert_eq!(app.mode, ViewMode::Main);
26112 journey_capture(&app, w, h, "main_return", &mut caps);
26113
26114 let artifact = journey_artifact("actionable→tree→attention", w, h, &caps);
26115 insta::assert_snapshot!(artifact);
26116 }
26117
26118 #[test]
26119 fn e2e_journey_main_search_focus_recovery() {
26120 let mut app = new_app(ViewMode::Main, 0);
26121 let (w, h) = (120, 35);
26122 let mut caps: Vec<(String, String)> = Vec::new();
26123
26124 let text = journey_capture(&app, w, h, "main_start", &mut caps);
26125 assert!(text.contains("Focus: list owns selection"));
26126
26127 app.update(key(KeyCode::Char('/')));
26128 app.update(key(KeyCode::Char('d')));
26129 let text = journey_capture(&app, w, h, "search_active", &mut caps);
26130 assert!(text.contains("Search (active): /d"));
26131
26132 app.update(key(KeyCode::Enter));
26133 let text = journey_capture(&app, w, h, "search_committed", &mut caps);
26134 assert!(text.contains("Matches: 1/3"));
26135 assert!(text.contains("hit 1/3"), "search committed frame: {text}");
26136
26137 app.update(key(KeyCode::Char('n')));
26138 let text = journey_capture(&app, w, h, "search_cycle_second_hit", &mut caps);
26139 assert!(text.contains("Matches: 2/3"));
26140 assert!(text.contains("hit 2/3"), "search cycled frame: {text}");
26141
26142 app.update(key(KeyCode::Tab));
26143 let text = journey_capture(&app, w, h, "detail_focus", &mut caps);
26144 assert!(text.contains("Focus: detail owns J/K deps"));
26145
26146 app.update(key(KeyCode::Escape));
26147 let text = journey_capture(&app, w, h, "focus_recovered", &mut caps);
26148 assert!(text.contains("Focus returned to list"));
26149
26150 app.update(key(KeyCode::Char('/')));
26151 app.update(key(KeyCode::Char('z')));
26152 app.update(key(KeyCode::Enter));
26153 let text = journey_capture(&app, w, h, "search_no_hit", &mut caps);
26154 assert!(text.contains("Matches: none in visible issues"));
26155
26156 app.update(key(KeyCode::Escape));
26157 let text = journey_capture(&app, w, h, "search_cleared", &mut caps);
26158 assert!(text.contains("Main search cleared"));
26159
26160 app.update(key(KeyCode::Char('o')));
26161 let text = journey_capture(&app, w, h, "open_filter", &mut caps);
26162 assert!(text.contains("scope=open"));
26163
26164 app.update(key(KeyCode::Escape));
26165 let text = journey_capture(&app, w, h, "filter_cleared", &mut caps);
26166 assert!(text.contains("scope=all"));
26167 }
26168
26169 #[test]
26170 fn e2e_journey_narrow_geometry_stress() {
26171 let mut app = new_app(ViewMode::Main, 0);
26173 let (w, h) = (40, 15);
26174 let mut caps: Vec<(String, String)> = Vec::new();
26175
26176 journey_capture(&app, w, h, "narrow_main", &mut caps);
26178
26179 app.update(key(KeyCode::Char('b')));
26181 journey_capture(&app, w, h, "narrow_board", &mut caps);
26182 app.update(key(KeyCode::Char('j')));
26183 app.update(key(KeyCode::Char('l')));
26184 journey_capture(&app, w, h, "narrow_board_nav", &mut caps);
26185
26186 app.update(key(KeyCode::Char('g')));
26188 journey_capture(&app, w, h, "narrow_graph", &mut caps);
26189
26190 app.update(key(KeyCode::Char('i')));
26192 journey_capture(&app, w, h, "narrow_insights", &mut caps);
26193 app.update(key(KeyCode::Char('s')));
26194 journey_capture(&app, w, h, "narrow_insights_cycle", &mut caps);
26195
26196 app.update(key(KeyCode::Escape));
26198 app.update(key(KeyCode::Char('a')));
26199 journey_capture(&app, w, h, "narrow_actionable", &mut caps);
26200
26201 app.update(key(KeyCode::Char('a')));
26203 app.update(key(KeyCode::Char('T')));
26204 journey_capture(&app, w, h, "narrow_tree", &mut caps);
26205
26206 app.update(key(KeyCode::Char('T')));
26208 journey_capture(&app, w, h, "narrow_main_return", &mut caps);
26209
26210 let artifact = journey_artifact("narrow-geometry-stress", w, h, &caps);
26211 insta::assert_snapshot!(artifact);
26212 }
26213
26214 #[test]
26215 fn e2e_journey_empty_data_edge_case() {
26216 let mut app = new_app_with_issues(ViewMode::Main, 0, vec![]);
26218 let (w, h) = (100, 30);
26219 let mut caps: Vec<(String, String)> = Vec::new();
26220
26221 let text = journey_capture(&app, w, h, "empty_main", &mut caps);
26223 assert!(
26224 text.contains("issues=0") || text.contains("No issues") || text.contains("mode=Main"),
26225 "empty main should render: {text}"
26226 );
26227
26228 app.update(key(KeyCode::Char('b')));
26230 let text = journey_capture(&app, w, h, "empty_board", &mut caps);
26231 assert!(!text.is_empty(), "empty board should render something");
26232
26233 app.update(key(KeyCode::Char('g')));
26235 journey_capture(&app, w, h, "empty_graph", &mut caps);
26236
26237 app.update(key(KeyCode::Char('i')));
26239 journey_capture(&app, w, h, "empty_insights", &mut caps);
26240
26241 app.update(key(KeyCode::Escape));
26243 app.update(key(KeyCode::Char('a')));
26244 let text = journey_capture(&app, w, h, "empty_actionable", &mut caps);
26245 assert!(
26246 text.contains("Actionable")
26247 || text.contains("Execution Tracks")
26248 || text.contains("ACTIONABLE"),
26249 "empty actionable should render: {text}"
26250 );
26251
26252 app.update(key(KeyCode::Char('a')));
26254 app.update(key(KeyCode::Char('T')));
26255 journey_capture(&app, w, h, "empty_tree", &mut caps);
26256
26257 app.update(key(KeyCode::Char('T')));
26259 journey_capture(&app, w, h, "empty_main_return", &mut caps);
26260
26261 let artifact = journey_artifact("empty-data-edge-case", w, h, &caps);
26262 insta::assert_snapshot!(artifact);
26263 }
26264
26265 #[test]
26266 fn e2e_journey_history_deep_dive() {
26267 let mut app = new_app(ViewMode::Main, 0);
26268 inject_deterministic_git_cache(&mut app);
26269 let (w, h) = (120, 35);
26270 let mut caps: Vec<(String, String)> = Vec::new();
26271
26272 app.update(key(KeyCode::Char('h')));
26274 assert_eq!(app.mode, ViewMode::History);
26275 let text = journey_capture(&app, w, h, "history_entry", &mut caps);
26276 assert!(!text.is_empty());
26277
26278 app.update(key(KeyCode::Char('j')));
26280 journey_capture(&app, w, h, "history_bead_nav", &mut caps);
26281
26282 app.update(key(KeyCode::Char('v')));
26284 journey_capture(&app, w, h, "history_git_mode", &mut caps);
26285
26286 app.update(key(KeyCode::Char('/')));
26288 for ch in "test".chars() {
26289 app.update(key(KeyCode::Char(ch)));
26290 }
26291 app.update(key(KeyCode::Enter));
26292 journey_capture(&app, w, h, "history_search", &mut caps);
26293
26294 app.update(key(KeyCode::Char('v')));
26296 journey_capture(&app, w, h, "history_bead_return", &mut caps);
26297
26298 app.update(key(KeyCode::Tab));
26300 journey_capture(&app, w, h, "history_middle_focus", &mut caps);
26301 app.update(key(KeyCode::Tab));
26302 journey_capture(&app, w, h, "history_detail_focus", &mut caps);
26303
26304 app.update(key(KeyCode::Escape));
26306 assert_eq!(app.mode, ViewMode::Main);
26307 journey_capture(&app, w, h, "main_after_history", &mut caps);
26308
26309 let artifact = journey_artifact("history-deep-dive", w, h, &caps);
26310 insta::assert_snapshot!(artifact);
26311 }
26312
26313 fn spans_text(spans: &[RichSpan<'_>]) -> String {
26317 RichLine::from_spans(spans.to_vec()).to_plain_text()
26318 }
26319
26320 fn span_text(span: &RichSpan<'_>) -> String {
26322 RichLine::from_spans([span.clone()]).to_plain_text()
26323 }
26324
26325 #[test]
26326 fn status_chip_covers_all_known_statuses() {
26327 for status in &[
26328 "open",
26329 "in_progress",
26330 "blocked",
26331 "closed",
26332 "deferred",
26333 "review",
26334 "pinned",
26335 "tombstone",
26336 "hooked",
26337 ] {
26338 let spans = status_chip(status);
26339 assert_eq!(
26340 spans.len(),
26341 2,
26342 "status_chip({status}) should return 2 spans"
26343 );
26344 let text = spans_text(&spans);
26345 assert!(!text.contains('?'), "known status {status} got '?' icon");
26346 }
26347 }
26348
26349 #[test]
26350 fn status_chip_unknown_shows_question() {
26351 let text = spans_text(&status_chip("nonexistent"));
26352 assert!(text.contains('?'));
26353 assert!(text.contains("unkn"));
26354 }
26355
26356 #[test]
26357 fn priority_badge_clamps_range() {
26358 assert_eq!(span_text(&priority_badge(0)), "P0");
26359 assert_eq!(span_text(&priority_badge(4)), "P4");
26360 assert_eq!(span_text(&priority_badge(-5)), "P0");
26361 assert_eq!(span_text(&priority_badge(99)), "P4");
26362 }
26363
26364 #[test]
26365 fn type_badge_maps_types() {
26366 assert_eq!(span_text(&type_badge("task")), "T");
26367 assert_eq!(span_text(&type_badge("bug")), "B");
26368 assert_eq!(span_text(&type_badge("epic")), "E");
26369 }
26370
26371 #[test]
26372 fn blocker_indicator_states() {
26373 assert!(blocker_indicator(0, 0).is_empty());
26374 assert!(spans_text(&blocker_indicator(3, 0)).contains('3'));
26375 assert!(spans_text(&blocker_indicator(0, 5)).contains('5'));
26376 }
26377
26378 #[test]
26379 fn metric_strip_content() {
26380 let text = spans_text(&metric_strip("PR", 0.42, 1.0));
26381 assert!(text.contains("PR"));
26382 assert!(text.contains("0.42"));
26383 }
26384
26385 #[test]
26386 fn section_separator_caps_width() {
26387 assert!(display_width(§ion_separator(200).to_plain_text()) <= 120);
26388 }
26389
26390 #[test]
26391 fn panel_header_content() {
26392 let h = panel_header("Issues", Some("3 open")).to_plain_text();
26393 assert!(h.contains("Issues"));
26394 assert!(h.contains("3 open"));
26395 assert_eq!(panel_header("Graph", None).to_plain_text(), "Graph");
26396 }
26397
26398 #[test]
26399 fn label_chips_content() {
26400 let text = spans_text(&label_chips(&["backend".into(), "urgent".into()]));
26401 assert!(text.contains("[backend]"));
26402 assert!(text.contains("[urgent]"));
26403 assert!(label_chips(&[]).is_empty());
26404 }
26405
26406 #[test]
26407 fn issue_scan_line_fields() {
26408 let issue = Issue {
26409 id: "BD-42".into(),
26410 title: "Fix widget".into(),
26411 status: "open".into(),
26412 priority: 1,
26413 issue_type: "bug".into(),
26414 ..Default::default()
26415 };
26416 let text = issue_scan_line(
26417 &issue,
26418 false,
26419 ScanLineContext {
26420 open_blockers: 0,
26421 blocks_count: 0,
26422 triage_rank: 3,
26423 pagerank_rank: 2,
26424 critical_depth: 1,
26425 graph_score: 0.0,
26426 search_match_position: None,
26427 total_search_matches: 0,
26428 diff_tag: None,
26429 available_width: 80,
26430 },
26431 )
26432 .to_plain_text();
26433 assert!(text.contains("BD-42"), "id missing: {text}");
26434 assert!(text.contains("Fix widget"), "title missing: {text}");
26435 assert!(text.contains("P1"), "priority missing: {text}");
26436 assert!(text.contains("#03"), "triage rank missing: {text}");
26437 }
26438
26439 #[test]
26440 fn issue_scan_line_selected_marker() {
26441 let issue = Issue {
26442 id: "A".into(),
26443 title: "Test".into(),
26444 status: "open".into(),
26445 issue_type: "task".into(),
26446 ..Default::default()
26447 };
26448 assert!(
26449 issue_scan_line(
26450 &issue,
26451 true,
26452 ScanLineContext {
26453 open_blockers: 0,
26454 blocks_count: 0,
26455 triage_rank: 1,
26456 pagerank_rank: 1,
26457 critical_depth: 0,
26458 graph_score: 0.0,
26459 search_match_position: None,
26460 total_search_matches: 0,
26461 diff_tag: None,
26462 available_width: 60,
26463 },
26464 )
26465 .to_plain_text()
26466 .starts_with('▸')
26467 );
26468 }
26469
26470 #[test]
26471 fn issue_scan_line_blocker() {
26472 let issue = Issue {
26473 id: "A".into(),
26474 title: "Blocked".into(),
26475 status: "blocked".into(),
26476 issue_type: "task".into(),
26477 ..Default::default()
26478 };
26479 let text = issue_scan_line(
26480 &issue,
26481 false,
26482 ScanLineContext {
26483 open_blockers: 2,
26484 blocks_count: 1,
26485 triage_rank: 4,
26486 pagerank_rank: 3,
26487 critical_depth: 2,
26488 graph_score: 0.0,
26489 search_match_position: None,
26490 total_search_matches: 0,
26491 diff_tag: None,
26492 available_width: 80,
26493 },
26494 )
26495 .to_plain_text();
26496 assert!(text.contains("⊘2"), "blocker missing: {text}");
26497 assert!(text.contains("↓1"), "downstream count missing: {text}");
26498 }
26499
26500 fn tree_fold_fixture_issues() -> Vec<Issue> {
26509 let p = Issue {
26510 id: "P".to_string(),
26511 title: "Parent".to_string(),
26512 status: "open".to_string(),
26513 issue_type: "task".to_string(),
26514 ..Issue::default()
26515 };
26516 let mk_child = |id: &str, parent: &str| Issue {
26517 id: id.to_string(),
26518 title: id.to_string(),
26519 status: "open".to_string(),
26520 issue_type: "task".to_string(),
26521 dependencies: vec![Dependency {
26522 issue_id: id.to_string(),
26523 depends_on_id: parent.to_string(),
26524 dep_type: "parent-child".to_string(),
26525 ..Dependency::default()
26526 }],
26527 ..Issue::default()
26528 };
26529 vec![
26530 p,
26531 mk_child("C1", "P"),
26532 mk_child("C2", "P"),
26533 mk_child("G", "C2"),
26534 mk_child("C3", "P"),
26535 ]
26536 }
26537
26538 fn tree_app_with(issues: Vec<Issue>) -> BvrApp {
26539 let mut app = new_app(ViewMode::Tree, 0);
26540 app.analyzer = Analyzer::new(issues);
26541 app.tree_cursor = 0;
26542 app.tree_collapsed = std::collections::HashSet::new();
26543 app.build_tree_flat_nodes();
26544 app
26545 }
26546
26547 fn tree_row_ids(app: &BvrApp) -> Vec<String> {
26548 app.tree_flat_nodes
26549 .iter()
26550 .map(|n| app.analyzer.issues[n.issue_index].id.clone())
26551 .collect()
26552 }
26553
26554 fn tree_cursor_id(app: &BvrApp) -> String {
26555 app.tree_flat_nodes
26556 .get(app.tree_cursor)
26557 .map(|n| app.analyzer.issues[n.issue_index].id.clone())
26558 .unwrap_or_default()
26559 }
26560
26561 #[test]
26562 fn tree_zc_on_leaf_closes_enclosing_parent_and_moves_cursor() {
26563 let mut app = tree_app_with(tree_fold_fixture_issues());
26564 let rows = tree_row_ids(&app);
26565 assert_eq!(rows, vec!["P", "C1", "C2", "G", "C3"]);
26567
26568 let c1_idx = rows.iter().position(|id| id == "C1").unwrap();
26570 app.tree_cursor = c1_idx;
26571 app.tree_collapse_current();
26572 assert_eq!(
26573 tree_row_ids(&app),
26574 vec!["P"],
26575 "all children should fold into P"
26576 );
26577 assert_eq!(
26578 tree_cursor_id(&app),
26579 "P",
26580 "cursor should land on the newly closed parent"
26581 );
26582 }
26583
26584 #[test]
26585 fn tree_zc_on_deep_leaf_closes_nearest_enclosing_ancestor() {
26586 let mut app = tree_app_with(tree_fold_fixture_issues());
26587 let rows = tree_row_ids(&app);
26588
26589 let g_idx = rows.iter().position(|id| id == "G").unwrap();
26592 app.tree_cursor = g_idx;
26593 app.tree_collapse_current();
26594
26595 let rows_after = tree_row_ids(&app);
26596 assert_eq!(rows_after, vec!["P", "C1", "C2", "C3"]);
26597 assert_eq!(tree_cursor_id(&app), "C2");
26598
26599 app.tree_collapse_current();
26601 assert_eq!(tree_row_ids(&app), vec!["P"]);
26602 assert_eq!(tree_cursor_id(&app), "P");
26603 }
26604
26605 #[test]
26606 fn tree_zc_on_open_parent_still_collapses_in_place() {
26607 let mut app = tree_app_with(tree_fold_fixture_issues());
26610 app.tree_cursor = 0;
26612 assert_eq!(tree_cursor_id(&app), "P");
26613 app.tree_collapse_current();
26614 assert_eq!(tree_row_ids(&app), vec!["P"]);
26615 assert_eq!(tree_cursor_id(&app), "P");
26616 }
26617
26618 #[test]
26619 fn tree_zc_on_orphan_root_leaf_is_noop() {
26620 let issues = vec![Issue {
26622 id: "SOLO".to_string(),
26623 title: "Solo".to_string(),
26624 status: "open".to_string(),
26625 issue_type: "task".to_string(),
26626 ..Issue::default()
26627 }];
26628 let mut app = tree_app_with(issues);
26629 assert_eq!(tree_row_ids(&app), vec!["SOLO"]);
26630 app.tree_cursor = 0;
26631 app.tree_collapse_current();
26632 assert_eq!(tree_row_ids(&app), vec!["SOLO"]);
26633 assert_eq!(tree_cursor_id(&app), "SOLO");
26634 }
26635}