feat(tui): Phase 2 detail screens — Issue Detail, MR Detail, discussion tree, cross-refs

Implements the remaining Phase 2 Core Screens:

- Discussion tree widget (view/common/discussion_tree.rs): DiscussionNode/NoteNode types,
  expand/collapse state, visual row flattening, format_relative_time with Clock trait
- Cross-reference widget (view/common/cross_ref.rs): CrossRefKind enum, navigable refs,
  badge rendering ([MR]/[REL]/[REF])
- Issue Detail (state + action + view): progressive hydration (metadata Phase 1,
  discussions Phase 2), section cycling, description scroll, sanitized GitLab content
- MR Detail (state + action + view): tab bar (Overview/Files/Discussions), file changes
  with change type indicators, branch info, draft/merge status, diff note support
- Message + update wiring: IssueDetailLoaded, MrDetailLoaded, DiscussionsLoaded handlers
  with TaskSupervisor stale-result guards

Closes bd-1d6z, bd-8ab7, bd-3t1b, bd-1cl9 (Phase 2 epic).
389 tests passing, clippy clean, fmt clean.
This commit is contained in:
teernisse
2026-02-18 15:03:30 -05:00
parent 90c8b43267
commit 050e00345a
12 changed files with 4589 additions and 21 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -332,6 +332,58 @@ impl LoreApp {
Cmd::none()
}
// --- Issue detail ---
Msg::IssueDetailLoaded {
generation,
key,
data,
} => {
let screen = Screen::IssueDetail(key.clone());
if self
.supervisor
.is_current(&TaskKey::LoadScreen(screen.clone()), generation)
{
self.state.issue_detail.apply_metadata(*data);
self.state.set_loading(screen.clone(), LoadState::Idle);
self.supervisor
.complete(&TaskKey::LoadScreen(screen), generation);
}
Cmd::none()
}
Msg::DiscussionsLoaded {
generation,
key,
discussions,
} => {
let screen = Screen::IssueDetail(key.clone());
if self
.supervisor
.is_current(&TaskKey::LoadScreen(screen.clone()), generation)
{
self.state.issue_detail.apply_discussions(discussions);
}
Cmd::none()
}
// --- MR detail ---
Msg::MrDetailLoaded {
generation,
key,
data,
} => {
let screen = Screen::MrDetail(key.clone());
if self
.supervisor
.is_current(&TaskKey::LoadScreen(screen.clone()), generation)
{
self.state.mr_detail.apply_metadata(*data);
self.state.set_loading(screen.clone(), LoadState::Idle);
self.supervisor
.complete(&TaskKey::LoadScreen(screen), generation);
}
Cmd::none()
}
// All other message variants: no-op for now.
// Future phases will fill these in as screens are implemented.
_ => Cmd::none(),

View File

@@ -240,20 +240,21 @@ pub enum Msg {
IssueDetailLoaded {
generation: u64,
key: EntityKey,
detail: Box<IssueDetail>,
data: Box<crate::state::issue_detail::IssueDetailData>,
},
// --- MR detail ---
MrDetailLoaded {
generation: u64,
key: EntityKey,
detail: Box<MrDetail>,
data: Box<crate::state::mr_detail::MrDetailData>,
},
// --- Discussions (shared by issue + MR detail) ---
DiscussionsLoaded {
generation: u64,
discussions: Vec<Discussion>,
key: EntityKey,
discussions: Vec<crate::view::common::discussion_tree::DiscussionNode>,
},
// --- Search ---

View File

@@ -1,14 +1,284 @@
#![allow(dead_code)]
#![allow(dead_code)] // Phase 2: consumed by Issue Detail screen
//! Issue detail screen state.
//!
//! Holds metadata, discussions, cross-references, and UI state for
//! viewing a single issue. Supports progressive hydration: metadata
//! loads first, discussions load async in a second phase.
use crate::message::{Discussion, EntityKey, IssueDetail};
use crate::message::EntityKey;
use crate::view::common::cross_ref::{CrossRef, CrossRefState};
use crate::view::common::discussion_tree::{DiscussionNode, DiscussionTreeState};
// ---------------------------------------------------------------------------
// IssueMetadata
// ---------------------------------------------------------------------------
/// Full metadata for a single issue, fetched from the local DB.
#[derive(Debug, Clone)]
pub struct IssueMetadata {
/// Issue IID (project-scoped).
pub iid: i64,
/// Project path (e.g., "group/project").
pub project_path: String,
/// Issue title.
pub title: String,
/// Issue description (markdown).
pub description: String,
/// Current state: "opened" or "closed".
pub state: String,
/// Author username.
pub author: String,
/// Assigned usernames.
pub assignees: Vec<String>,
/// Label names.
pub labels: Vec<String>,
/// Milestone title (if set).
pub milestone: Option<String>,
/// Due date (if set, "YYYY-MM-DD").
pub due_date: Option<String>,
/// Created timestamp (ms epoch).
pub created_at: i64,
/// Updated timestamp (ms epoch).
pub updated_at: i64,
/// GitLab web URL for "open in browser".
pub web_url: String,
/// Discussion count (for display before discussions load).
pub discussion_count: usize,
}
// ---------------------------------------------------------------------------
// IssueDetailData
// ---------------------------------------------------------------------------
/// Bundle returned by the metadata fetch action.
///
/// Metadata + cross-refs load in Phase 1 (fast). Discussions load
/// separately in Phase 2.
#[derive(Debug, Clone)]
pub struct IssueDetailData {
pub metadata: IssueMetadata,
pub cross_refs: Vec<CrossRef>,
}
// ---------------------------------------------------------------------------
// DetailSection
// ---------------------------------------------------------------------------
/// Which section of the detail view has keyboard focus.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DetailSection {
/// Description area (scrollable text).
#[default]
Description,
/// Discussion tree.
Discussions,
/// Cross-references list.
CrossRefs,
}
impl DetailSection {
/// Cycle to the next section.
#[must_use]
pub fn next(self) -> Self {
match self {
Self::Description => Self::Discussions,
Self::Discussions => Self::CrossRefs,
Self::CrossRefs => Self::Description,
}
}
/// Cycle to the previous section.
#[must_use]
pub fn prev(self) -> Self {
match self {
Self::Description => Self::CrossRefs,
Self::Discussions => Self::Description,
Self::CrossRefs => Self::Discussions,
}
}
}
// ---------------------------------------------------------------------------
// IssueDetailState
// ---------------------------------------------------------------------------
/// State for the issue detail screen.
#[derive(Debug, Default)]
pub struct IssueDetailState {
pub key: Option<EntityKey>,
pub detail: Option<IssueDetail>,
pub discussions: Vec<Discussion>,
pub scroll_offset: u16,
/// Entity key for the currently displayed issue.
pub current_key: Option<EntityKey>,
/// Issue metadata (Phase 1 load).
pub metadata: Option<IssueMetadata>,
/// Discussion nodes (Phase 2 async load).
pub discussions: Vec<DiscussionNode>,
/// Whether discussions have finished loading.
pub discussions_loaded: bool,
/// Cross-references (loaded with metadata in Phase 1).
pub cross_refs: Vec<CrossRef>,
/// Discussion tree UI state (expand/collapse, selection).
pub tree_state: DiscussionTreeState,
/// Cross-reference list UI state.
pub cross_ref_state: CrossRefState,
/// Description scroll offset.
pub description_scroll: usize,
/// Active section for keyboard focus.
pub active_section: DetailSection,
}
impl IssueDetailState {
/// Reset state for a new issue.
pub fn load_new(&mut self, key: EntityKey) {
self.current_key = Some(key);
self.metadata = None;
self.discussions.clear();
self.discussions_loaded = false;
self.cross_refs.clear();
self.tree_state = DiscussionTreeState::default();
self.cross_ref_state = CrossRefState::default();
self.description_scroll = 0;
self.active_section = DetailSection::Description;
}
/// Apply Phase 1 data (metadata + cross-refs).
pub fn apply_metadata(&mut self, data: IssueDetailData) {
self.metadata = Some(data.metadata);
self.cross_refs = data.cross_refs;
}
/// Apply Phase 2 data (discussions).
pub fn apply_discussions(&mut self, discussions: Vec<DiscussionNode>) {
self.discussions = discussions;
self.discussions_loaded = true;
}
/// Whether we have metadata loaded for the current key.
#[must_use]
pub fn has_metadata(&self) -> bool {
self.metadata.is_some()
}
/// Cycle to the next section.
pub fn next_section(&mut self) {
self.active_section = self.active_section.next();
}
/// Cycle to the previous section.
pub fn prev_section(&mut self) {
self.active_section = self.active_section.prev();
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::view::common::cross_ref::CrossRefKind;
#[test]
fn test_issue_detail_state_default() {
let state = IssueDetailState::default();
assert!(state.current_key.is_none());
assert!(state.metadata.is_none());
assert!(state.discussions.is_empty());
assert!(!state.discussions_loaded);
assert!(state.cross_refs.is_empty());
assert_eq!(state.active_section, DetailSection::Description);
}
#[test]
fn test_load_new_resets_state() {
let mut state = IssueDetailState {
discussions_loaded: true,
description_scroll: 10,
active_section: DetailSection::CrossRefs,
..IssueDetailState::default()
};
state.load_new(EntityKey::issue(1, 42));
assert_eq!(state.current_key, Some(EntityKey::issue(1, 42)));
assert!(state.metadata.is_none());
assert!(!state.discussions_loaded);
assert_eq!(state.description_scroll, 0);
assert_eq!(state.active_section, DetailSection::Description);
}
#[test]
fn test_apply_metadata() {
let mut state = IssueDetailState::default();
state.load_new(EntityKey::issue(1, 42));
let data = IssueDetailData {
metadata: IssueMetadata {
iid: 42,
project_path: "group/proj".into(),
title: "Fix auth".into(),
description: "Description here".into(),
state: "opened".into(),
author: "alice".into(),
assignees: vec!["bob".into()],
labels: vec!["backend".into()],
milestone: Some("v1.0".into()),
due_date: None,
created_at: 1_700_000_000_000,
updated_at: 1_700_000_060_000,
web_url: "https://gitlab.com/group/proj/-/issues/42".into(),
discussion_count: 3,
},
cross_refs: vec![CrossRef {
kind: CrossRefKind::ClosingMr,
entity_key: EntityKey::mr(1, 10),
label: "Fix auth MR".into(),
navigable: true,
}],
};
state.apply_metadata(data);
assert!(state.has_metadata());
assert_eq!(state.metadata.as_ref().unwrap().iid, 42);
assert_eq!(state.cross_refs.len(), 1);
}
#[test]
fn test_apply_discussions() {
let mut state = IssueDetailState::default();
assert!(!state.discussions_loaded);
let discussions = vec![DiscussionNode {
discussion_id: "d1".into(),
notes: vec![],
resolvable: false,
resolved: false,
}];
state.apply_discussions(discussions);
assert!(state.discussions_loaded);
assert_eq!(state.discussions.len(), 1);
}
#[test]
fn test_detail_section_cycling() {
let section = DetailSection::Description;
assert_eq!(section.next(), DetailSection::Discussions);
assert_eq!(section.next().next(), DetailSection::CrossRefs);
assert_eq!(section.next().next().next(), DetailSection::Description);
assert_eq!(section.prev(), DetailSection::CrossRefs);
assert_eq!(section.prev().prev(), DetailSection::Discussions);
}
#[test]
fn test_section_next_prev_round_trip() {
let mut state = IssueDetailState::default();
assert_eq!(state.active_section, DetailSection::Description);
state.next_section();
assert_eq!(state.active_section, DetailSection::Discussions);
state.prev_section();
assert_eq!(state.active_section, DetailSection::Description);
}
}

View File

@@ -1,14 +1,387 @@
#![allow(dead_code)]
#![allow(dead_code)] // Phase 2: consumed by MR Detail screen
//! Merge request detail screen state.
//!
//! Holds MR metadata, file changes, discussions, cross-references,
//! and UI state. Supports progressive hydration identical to
//! Issue Detail: metadata loads first, discussions load async.
use crate::message::{Discussion, EntityKey, MrDetail};
use crate::message::EntityKey;
use crate::view::common::cross_ref::{CrossRef, CrossRefState};
use crate::view::common::discussion_tree::{DiscussionNode, DiscussionTreeState};
// ---------------------------------------------------------------------------
// MrMetadata
// ---------------------------------------------------------------------------
/// Full metadata for a single merge request, fetched from the local DB.
#[derive(Debug, Clone)]
pub struct MrMetadata {
/// MR IID (project-scoped).
pub iid: i64,
/// Project path (e.g., "group/project").
pub project_path: String,
/// MR title.
pub title: String,
/// MR description (markdown).
pub description: String,
/// Current state: "opened", "merged", "closed", "locked".
pub state: String,
/// Whether this is a draft/WIP MR.
pub draft: bool,
/// Author username.
pub author: String,
/// Assigned usernames.
pub assignees: Vec<String>,
/// Reviewer usernames.
pub reviewers: Vec<String>,
/// Label names.
pub labels: Vec<String>,
/// Source branch name.
pub source_branch: String,
/// Target branch name.
pub target_branch: String,
/// Detailed merge status (e.g., "mergeable", "checking").
pub merge_status: String,
/// Created timestamp (ms epoch).
pub created_at: i64,
/// Updated timestamp (ms epoch).
pub updated_at: i64,
/// Merged timestamp (ms epoch), if merged.
pub merged_at: Option<i64>,
/// GitLab web URL.
pub web_url: String,
/// Discussion count (for display before discussions load).
pub discussion_count: usize,
/// File change count.
pub file_change_count: usize,
}
// ---------------------------------------------------------------------------
// FileChange
// ---------------------------------------------------------------------------
/// A file changed in the merge request.
#[derive(Debug, Clone)]
pub struct FileChange {
/// Previous file path (if renamed).
pub old_path: Option<String>,
/// New/current file path.
pub new_path: String,
/// Type of change.
pub change_type: FileChangeType,
}
/// The type of file change in an MR.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileChangeType {
Added,
Modified,
Deleted,
Renamed,
}
impl FileChangeType {
/// Short icon for display.
#[must_use]
pub const fn icon(&self) -> &str {
match self {
Self::Added => "+",
Self::Modified => "~",
Self::Deleted => "-",
Self::Renamed => "R",
}
}
/// Parse from DB string.
#[must_use]
pub fn parse_db(s: &str) -> Self {
match s {
"added" => Self::Added,
"deleted" => Self::Deleted,
"renamed" => Self::Renamed,
_ => Self::Modified,
}
}
}
// ---------------------------------------------------------------------------
// MrDetailData
// ---------------------------------------------------------------------------
/// Bundle returned by the metadata fetch action.
///
/// Metadata + cross-refs + file changes load in Phase 1 (fast).
/// Discussions load separately in Phase 2.
#[derive(Debug, Clone)]
pub struct MrDetailData {
pub metadata: MrMetadata,
pub cross_refs: Vec<CrossRef>,
pub file_changes: Vec<FileChange>,
}
// ---------------------------------------------------------------------------
// MrTab
// ---------------------------------------------------------------------------
/// Active tab in the MR detail view.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MrTab {
/// Overview: description + cross-refs.
#[default]
Overview,
/// File changes list.
Files,
/// Discussions (general + diff).
Discussions,
}
impl MrTab {
/// Cycle to the next tab.
#[must_use]
pub fn next(self) -> Self {
match self {
Self::Overview => Self::Files,
Self::Files => Self::Discussions,
Self::Discussions => Self::Overview,
}
}
/// Cycle to the previous tab.
#[must_use]
pub fn prev(self) -> Self {
match self {
Self::Overview => Self::Discussions,
Self::Files => Self::Overview,
Self::Discussions => Self::Files,
}
}
/// Human-readable label.
#[must_use]
pub const fn label(&self) -> &str {
match self {
Self::Overview => "Overview",
Self::Files => "Files",
Self::Discussions => "Discussions",
}
}
}
// ---------------------------------------------------------------------------
// MrDetailState
// ---------------------------------------------------------------------------
/// State for the MR detail screen.
#[derive(Debug, Default)]
pub struct MrDetailState {
pub key: Option<EntityKey>,
pub detail: Option<MrDetail>,
pub discussions: Vec<Discussion>,
pub scroll_offset: u16,
/// Entity key for the currently displayed MR.
pub current_key: Option<EntityKey>,
/// MR metadata (Phase 1 load).
pub metadata: Option<MrMetadata>,
/// File changes (loaded with metadata in Phase 1).
pub file_changes: Vec<FileChange>,
/// Discussion nodes (Phase 2 async load).
pub discussions: Vec<DiscussionNode>,
/// Whether discussions have finished loading.
pub discussions_loaded: bool,
/// Cross-references (loaded with metadata in Phase 1).
pub cross_refs: Vec<CrossRef>,
/// Discussion tree UI state.
pub tree_state: DiscussionTreeState,
/// Cross-reference list UI state.
pub cross_ref_state: CrossRefState,
/// Description scroll offset.
pub description_scroll: usize,
/// File list selected index.
pub file_selected: usize,
/// File list scroll offset.
pub file_scroll: usize,
/// Active tab.
pub active_tab: MrTab,
}
impl MrDetailState {
/// Reset state for a new MR.
pub fn load_new(&mut self, key: EntityKey) {
self.current_key = Some(key);
self.metadata = None;
self.file_changes.clear();
self.discussions.clear();
self.discussions_loaded = false;
self.cross_refs.clear();
self.tree_state = DiscussionTreeState::default();
self.cross_ref_state = CrossRefState::default();
self.description_scroll = 0;
self.file_selected = 0;
self.file_scroll = 0;
self.active_tab = MrTab::Overview;
}
/// Apply Phase 1 data (metadata + cross-refs + file changes).
pub fn apply_metadata(&mut self, data: MrDetailData) {
self.metadata = Some(data.metadata);
self.cross_refs = data.cross_refs;
self.file_changes = data.file_changes;
}
/// Apply Phase 2 data (discussions).
pub fn apply_discussions(&mut self, discussions: Vec<DiscussionNode>) {
self.discussions = discussions;
self.discussions_loaded = true;
}
/// Whether we have metadata loaded.
#[must_use]
pub fn has_metadata(&self) -> bool {
self.metadata.is_some()
}
/// Switch to the next tab.
pub fn next_tab(&mut self) {
self.active_tab = self.active_tab.next();
}
/// Switch to the previous tab.
pub fn prev_tab(&mut self) {
self.active_tab = self.active_tab.prev();
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::view::common::cross_ref::CrossRefKind;
#[test]
fn test_mr_detail_state_default() {
let state = MrDetailState::default();
assert!(state.current_key.is_none());
assert!(state.metadata.is_none());
assert!(state.discussions.is_empty());
assert!(!state.discussions_loaded);
assert!(state.file_changes.is_empty());
assert_eq!(state.active_tab, MrTab::Overview);
}
#[test]
fn test_load_new_resets_state() {
let mut state = MrDetailState {
discussions_loaded: true,
description_scroll: 10,
active_tab: MrTab::Files,
..MrDetailState::default()
};
state.load_new(EntityKey::mr(1, 42));
assert_eq!(state.current_key, Some(EntityKey::mr(1, 42)));
assert!(state.metadata.is_none());
assert!(!state.discussions_loaded);
assert_eq!(state.description_scroll, 0);
assert_eq!(state.active_tab, MrTab::Overview);
}
#[test]
fn test_apply_metadata() {
let mut state = MrDetailState::default();
state.load_new(EntityKey::mr(1, 42));
let data = MrDetailData {
metadata: MrMetadata {
iid: 42,
project_path: "group/proj".into(),
title: "Fix auth".into(),
description: "MR description".into(),
state: "opened".into(),
draft: false,
author: "alice".into(),
assignees: vec!["bob".into()],
reviewers: vec!["carol".into()],
labels: vec!["backend".into()],
source_branch: "fix-auth".into(),
target_branch: "main".into(),
merge_status: "mergeable".into(),
created_at: 1_700_000_000_000,
updated_at: 1_700_000_060_000,
merged_at: None,
web_url: "https://gitlab.com/group/proj/-/merge_requests/42".into(),
discussion_count: 2,
file_change_count: 3,
},
cross_refs: vec![CrossRef {
kind: CrossRefKind::RelatedIssue,
entity_key: EntityKey::issue(1, 10),
label: "Related issue".into(),
navigable: true,
}],
file_changes: vec![FileChange {
old_path: None,
new_path: "src/auth.rs".into(),
change_type: FileChangeType::Modified,
}],
};
state.apply_metadata(data);
assert!(state.has_metadata());
assert_eq!(state.metadata.as_ref().unwrap().iid, 42);
assert_eq!(state.cross_refs.len(), 1);
assert_eq!(state.file_changes.len(), 1);
}
#[test]
fn test_tab_cycling() {
let tab = MrTab::Overview;
assert_eq!(tab.next(), MrTab::Files);
assert_eq!(tab.next().next(), MrTab::Discussions);
assert_eq!(tab.next().next().next(), MrTab::Overview);
assert_eq!(tab.prev(), MrTab::Discussions);
assert_eq!(tab.prev().prev(), MrTab::Files);
}
#[test]
fn test_tab_labels() {
assert_eq!(MrTab::Overview.label(), "Overview");
assert_eq!(MrTab::Files.label(), "Files");
assert_eq!(MrTab::Discussions.label(), "Discussions");
}
#[test]
fn test_file_change_type_icon() {
assert_eq!(FileChangeType::Added.icon(), "+");
assert_eq!(FileChangeType::Modified.icon(), "~");
assert_eq!(FileChangeType::Deleted.icon(), "-");
assert_eq!(FileChangeType::Renamed.icon(), "R");
}
#[test]
fn test_file_change_type_parse_db() {
assert_eq!(FileChangeType::parse_db("added"), FileChangeType::Added);
assert_eq!(FileChangeType::parse_db("deleted"), FileChangeType::Deleted);
assert_eq!(FileChangeType::parse_db("renamed"), FileChangeType::Renamed);
assert_eq!(
FileChangeType::parse_db("modified"),
FileChangeType::Modified
);
assert_eq!(
FileChangeType::parse_db("unknown"),
FileChangeType::Modified
);
}
#[test]
fn test_next_prev_tab_on_state() {
let mut state = MrDetailState::default();
assert_eq!(state.active_tab, MrTab::Overview);
state.next_tab();
assert_eq!(state.active_tab, MrTab::Files);
state.prev_tab();
assert_eq!(state.active_tab, MrTab::Overview);
}
}

View File

@@ -0,0 +1,410 @@
#![allow(dead_code)] // Phase 2: consumed by Issue Detail + MR Detail screens
//! Cross-reference widget for entity detail screens.
//!
//! Renders a list of linked entities (closing MRs, related issues, mentions)
//! as navigable items. Used in both Issue Detail and MR Detail views.
use std::fmt;
use ftui::core::geometry::Rect;
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::message::EntityKey;
// ---------------------------------------------------------------------------
// CrossRefKind
// ---------------------------------------------------------------------------
/// The relationship type between two entities.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CrossRefKind {
/// MR that closes this issue when merged.
ClosingMr,
/// Issue related via GitLab link.
RelatedIssue,
/// Entity mentioned in a note or description.
MentionedIn,
}
impl CrossRefKind {
/// Short icon/prefix for display.
#[must_use]
pub const fn icon(&self) -> &str {
match self {
Self::ClosingMr => "MR",
Self::RelatedIssue => "REL",
Self::MentionedIn => "REF",
}
}
}
impl fmt::Display for CrossRefKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ClosingMr => write!(f, "Closing MR"),
Self::RelatedIssue => write!(f, "Related Issue"),
Self::MentionedIn => write!(f, "Mentioned In"),
}
}
}
// ---------------------------------------------------------------------------
// CrossRef
// ---------------------------------------------------------------------------
/// A single cross-reference to another entity.
#[derive(Debug, Clone)]
pub struct CrossRef {
/// Relationship type.
pub kind: CrossRefKind,
/// Target entity identity.
pub entity_key: EntityKey,
/// Human-readable label (e.g., "Fix authentication flow").
pub label: String,
/// Whether this ref points to an entity in the local DB (navigable).
pub navigable: bool,
}
// ---------------------------------------------------------------------------
// CrossRefState
// ---------------------------------------------------------------------------
/// Rendering state for the cross-reference list.
#[derive(Debug, Clone, Default)]
pub struct CrossRefState {
/// Index of the selected cross-reference.
pub selected: usize,
/// First visible item index.
pub scroll_offset: usize,
}
impl CrossRefState {
/// Move selection down.
pub fn select_next(&mut self, total: usize) {
if total > 0 && self.selected < total - 1 {
self.selected += 1;
}
}
/// Move selection up.
pub fn select_prev(&mut self) {
self.selected = self.selected.saturating_sub(1);
}
}
// ---------------------------------------------------------------------------
// Colors
// ---------------------------------------------------------------------------
/// Color scheme for cross-reference rendering.
pub struct CrossRefColors {
/// Foreground for the kind icon/badge.
pub kind_fg: PackedRgba,
/// Foreground for the label text.
pub label_fg: PackedRgba,
/// Muted foreground for non-navigable refs.
pub muted_fg: PackedRgba,
/// Selected item foreground.
pub selected_fg: PackedRgba,
/// Selected item background.
pub selected_bg: PackedRgba,
}
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
/// Render a list of cross-references within the given area.
///
/// Returns the number of rows consumed.
///
/// Layout per row:
/// ```text
/// [MR] !42 Fix authentication flow
/// [REL] #15 Related auth issue
/// [REF] !99 Mentioned in pipeline MR
/// ```
pub fn render_cross_refs(
frame: &mut Frame<'_>,
refs: &[CrossRef],
state: &CrossRefState,
area: Rect,
colors: &CrossRefColors,
) -> u16 {
if refs.is_empty() || area.height == 0 || area.width < 10 {
return 0;
}
let max_x = area.x.saturating_add(area.width);
let visible_count = (area.height as usize).min(refs.len().saturating_sub(state.scroll_offset));
for i in 0..visible_count {
let idx = state.scroll_offset + i;
let Some(cr) = refs.get(idx) else { break };
let y = area.y + i as u16;
let is_selected = idx == state.selected;
// Background fill for selected row.
if is_selected {
frame.draw_rect_filled(
Rect::new(area.x, y, area.width, 1),
Cell {
fg: colors.selected_fg,
bg: colors.selected_bg,
..Cell::default()
},
);
}
let mut x = area.x;
// Kind badge: [MR], [REL], [REF]
let badge = format!("[{}]", cr.kind.icon());
let badge_style = if is_selected {
Cell {
fg: colors.selected_fg,
bg: colors.selected_bg,
..Cell::default()
}
} else {
Cell {
fg: colors.kind_fg,
..Cell::default()
}
};
x = frame.print_text_clipped(x, y, &badge, badge_style, max_x);
// Spacing
x = frame.print_text_clipped(x, y, " ", badge_style, max_x);
// Entity prefix + label
let prefix = match cr.kind {
CrossRefKind::ClosingMr | CrossRefKind::MentionedIn => {
format!("!{} ", cr.entity_key.iid)
}
CrossRefKind::RelatedIssue => {
format!("#{} ", cr.entity_key.iid)
}
};
let label_style = if is_selected {
Cell {
fg: colors.selected_fg,
bg: colors.selected_bg,
..Cell::default()
}
} else if cr.navigable {
Cell {
fg: colors.label_fg,
..Cell::default()
}
} else {
Cell {
fg: colors.muted_fg,
..Cell::default()
}
};
x = frame.print_text_clipped(x, y, &prefix, label_style, max_x);
let _ = frame.print_text_clipped(x, y, &cr.label, label_style, max_x);
}
visible_count as u16
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use ftui::render::grapheme_pool::GraphemePool;
macro_rules! with_frame {
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
let mut pool = GraphemePool::new();
let mut $frame = Frame::new($width, $height, &mut pool);
$body
}};
}
fn sample_refs() -> Vec<CrossRef> {
vec![
CrossRef {
kind: CrossRefKind::ClosingMr,
entity_key: EntityKey::mr(1, 42),
label: "Fix authentication flow".into(),
navigable: true,
},
CrossRef {
kind: CrossRefKind::RelatedIssue,
entity_key: EntityKey::issue(1, 15),
label: "Related auth issue".into(),
navigable: true,
},
CrossRef {
kind: CrossRefKind::MentionedIn,
entity_key: EntityKey::mr(2, 99),
label: "Pipeline improvements".into(),
navigable: false,
},
]
}
fn test_colors() -> CrossRefColors {
CrossRefColors {
kind_fg: PackedRgba::rgb(0xDA, 0x70, 0x2C),
label_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
muted_fg: PackedRgba::rgb(0x87, 0x87, 0x80),
selected_fg: PackedRgba::rgb(0x10, 0x0F, 0x0F),
selected_bg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
}
}
// TDD anchor test from bead spec.
#[test]
fn test_cross_ref_entity_key() {
let cr = CrossRef {
kind: CrossRefKind::ClosingMr,
entity_key: EntityKey::mr(1, 42),
label: "Fix auth".into(),
navigable: true,
};
assert_eq!(cr.kind, CrossRefKind::ClosingMr);
assert_eq!(cr.entity_key, EntityKey::mr(1, 42));
}
#[test]
fn test_cross_ref_kind_display() {
assert_eq!(CrossRefKind::ClosingMr.to_string(), "Closing MR");
assert_eq!(CrossRefKind::RelatedIssue.to_string(), "Related Issue");
assert_eq!(CrossRefKind::MentionedIn.to_string(), "Mentioned In");
}
#[test]
fn test_cross_ref_kind_icon() {
assert_eq!(CrossRefKind::ClosingMr.icon(), "MR");
assert_eq!(CrossRefKind::RelatedIssue.icon(), "REL");
assert_eq!(CrossRefKind::MentionedIn.icon(), "REF");
}
#[test]
fn test_cross_ref_state_navigation() {
let mut state = CrossRefState::default();
assert_eq!(state.selected, 0);
state.select_next(3);
assert_eq!(state.selected, 1);
state.select_next(3);
assert_eq!(state.selected, 2);
// Can't go past end.
state.select_next(3);
assert_eq!(state.selected, 2);
state.select_prev();
assert_eq!(state.selected, 1);
state.select_prev();
assert_eq!(state.selected, 0);
// Can't go before start.
state.select_prev();
assert_eq!(state.selected, 0);
}
#[test]
fn test_render_cross_refs_no_panic() {
with_frame!(80, 10, |frame| {
let refs = sample_refs();
let state = CrossRefState::default();
let rows = render_cross_refs(
&mut frame,
&refs,
&state,
Rect::new(0, 0, 80, 10),
&test_colors(),
);
assert_eq!(rows, 3);
});
}
#[test]
fn test_render_cross_refs_empty() {
with_frame!(80, 10, |frame| {
let state = CrossRefState::default();
let rows = render_cross_refs(
&mut frame,
&[],
&state,
Rect::new(0, 0, 80, 10),
&test_colors(),
);
assert_eq!(rows, 0);
});
}
#[test]
fn test_render_cross_refs_tiny_area() {
with_frame!(5, 1, |frame| {
let refs = sample_refs();
let state = CrossRefState::default();
let rows = render_cross_refs(
&mut frame,
&refs,
&state,
Rect::new(0, 0, 5, 1),
&test_colors(),
);
// Too narrow (< 10), should bail.
assert_eq!(rows, 0);
});
}
#[test]
fn test_render_cross_refs_with_scroll() {
with_frame!(80, 2, |frame| {
let refs = sample_refs();
let state = CrossRefState {
selected: 2,
scroll_offset: 1,
};
let rows = render_cross_refs(
&mut frame,
&refs,
&state,
Rect::new(0, 0, 80, 2),
&test_colors(),
);
// 2 visible (indices 1 and 2).
assert_eq!(rows, 2);
});
}
#[test]
fn test_render_cross_refs_non_navigable() {
with_frame!(80, 5, |frame| {
let refs = vec![CrossRef {
kind: CrossRefKind::MentionedIn,
entity_key: EntityKey::mr(2, 99),
label: "Non-local entity".into(),
navigable: false,
}];
let state = CrossRefState::default();
let rows = render_cross_refs(
&mut frame,
&refs,
&state,
Rect::new(0, 0, 80, 5),
&test_colors(),
);
assert_eq!(rows, 1);
});
}
}

View File

@@ -0,0 +1,979 @@
#![allow(dead_code)] // Phase 2: consumed by Issue Detail + MR Detail screens
//! Discussion tree widget for entity detail screens.
//!
//! Renders threaded conversations from GitLab issues/MRs. Discussions are
//! top-level expandable nodes, with notes as children. Supports expand/collapse
//! persistence, system note styling, and diff note file path rendering.
use std::collections::HashSet;
use ftui::core::geometry::Rect;
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::clock::Clock;
use crate::safety::{UrlPolicy, sanitize_for_terminal};
// ---------------------------------------------------------------------------
// Data types
// ---------------------------------------------------------------------------
/// A single discussion thread (top-level node).
#[derive(Debug, Clone)]
pub struct DiscussionNode {
/// GitLab discussion ID (used as expand/collapse key).
pub discussion_id: String,
/// Notes within this discussion, ordered by position.
pub notes: Vec<NoteNode>,
/// Whether this discussion is resolvable (MR discussions only).
pub resolvable: bool,
/// Whether this discussion has been resolved.
pub resolved: bool,
}
impl DiscussionNode {
/// Summary line for collapsed display.
fn summary(&self) -> String {
let first = self.notes.first();
let author = first.map_or("unknown", |n| n.author.as_str());
let note_count = self.notes.len();
let resolved_tag = if self.resolved { " [resolved]" } else { "" };
if note_count == 1 {
format!("{author}{resolved_tag}")
} else {
format!("{author} ({note_count} notes){resolved_tag}")
}
}
/// First line of the first note body, sanitized and truncated.
fn preview(&self, max_chars: usize) -> String {
self.notes
.first()
.and_then(|n| n.body.lines().next())
.map(|line| {
let sanitized = sanitize_for_terminal(line, UrlPolicy::Strip);
if sanitized.len() > max_chars {
let trunc = max_chars.saturating_sub(3);
// Find the last valid char boundary at or before `trunc`
// to avoid panicking on multi-byte UTF-8 (emoji, CJK).
let safe_end = sanitized
.char_indices()
.take_while(|&(i, _)| i <= trunc)
.last()
.map_or(0, |(i, c)| i + c.len_utf8());
format!("{}...", &sanitized[..safe_end])
} else {
sanitized
}
})
.unwrap_or_default()
}
}
/// A single note within a discussion.
#[derive(Debug, Clone)]
pub struct NoteNode {
/// Author username.
pub author: String,
/// Note body (markdown text from GitLab).
pub body: String,
/// Creation timestamp in milliseconds since epoch.
pub created_at: i64,
/// Whether this is a system-generated note.
pub is_system: bool,
/// Whether this is a diff/code review note.
pub is_diff_note: bool,
/// File path for diff notes.
pub diff_file_path: Option<String>,
/// New line number for diff notes.
pub diff_new_line: Option<i64>,
}
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
/// Rendering state for the discussion tree.
#[derive(Debug, Clone, Default)]
pub struct DiscussionTreeState {
/// Index of the selected discussion (0-based).
pub selected: usize,
/// First visible row index for scrolling.
pub scroll_offset: usize,
/// Set of expanded discussion IDs.
pub expanded: HashSet<String>,
}
impl DiscussionTreeState {
/// Move selection down.
pub fn select_next(&mut self, total: usize) {
if total > 0 && self.selected < total - 1 {
self.selected += 1;
}
}
/// Move selection up.
pub fn select_prev(&mut self) {
self.selected = self.selected.saturating_sub(1);
}
/// Toggle expand/collapse for the selected discussion.
pub fn toggle_selected(&mut self, discussions: &[DiscussionNode]) {
if let Some(d) = discussions.get(self.selected) {
let id = &d.discussion_id;
if self.expanded.contains(id) {
self.expanded.remove(id);
} else {
self.expanded.insert(id.clone());
}
}
}
/// Whether a discussion is expanded.
#[must_use]
pub fn is_expanded(&self, discussion_id: &str) -> bool {
self.expanded.contains(discussion_id)
}
}
// ---------------------------------------------------------------------------
// Colors
// ---------------------------------------------------------------------------
/// Color scheme for discussion tree rendering.
pub struct DiscussionTreeColors {
/// Author name foreground.
pub author_fg: PackedRgba,
/// Timestamp foreground.
pub timestamp_fg: PackedRgba,
/// Note body foreground.
pub body_fg: PackedRgba,
/// System note foreground (muted).
pub system_fg: PackedRgba,
/// Diff file path foreground.
pub diff_path_fg: PackedRgba,
/// Resolved indicator foreground.
pub resolved_fg: PackedRgba,
/// Tree guide characters.
pub guide_fg: PackedRgba,
/// Selected discussion background.
pub selected_fg: PackedRgba,
/// Selected discussion background.
pub selected_bg: PackedRgba,
/// Expand/collapse indicator.
pub expand_fg: PackedRgba,
}
// ---------------------------------------------------------------------------
// Relative time formatting
// ---------------------------------------------------------------------------
/// Format a timestamp as a human-readable relative time string.
///
/// Uses the provided `Clock` for deterministic rendering in tests.
#[must_use]
pub fn format_relative_time(epoch_ms: i64, clock: &dyn Clock) -> String {
let now_ms = clock.now_ms();
let diff_ms = now_ms.saturating_sub(epoch_ms);
if diff_ms < 0 {
return "just now".to_string();
}
let seconds = diff_ms / 1_000;
let minutes = seconds / 60;
let hours = minutes / 60;
let days = hours / 24;
let weeks = days / 7;
let months = days / 30;
if seconds < 60 {
"just now".to_string()
} else if minutes < 60 {
format!("{minutes}m ago")
} else if hours < 24 {
format!("{hours}h ago")
} else if days < 7 {
format!("{days}d ago")
} else if weeks < 4 {
format!("{weeks}w ago")
} else {
format!("{months}mo ago")
}
}
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
/// Maximum indent depth for nested content (notes within discussions).
const INDENT: u16 = 4;
/// Render a discussion tree within the given area.
///
/// Returns the number of rows consumed.
///
/// Layout:
/// ```text
/// > alice (3 notes) [resolved] <- collapsed discussion
/// First line of note body preview...
///
/// v bob (2 notes) <- expanded discussion
/// | bob · 3h ago
/// | This is the first note body...
/// |
/// | alice · 1h ago <- diff note
/// | diff src/auth.rs:42
/// | Code review comment about...
/// ```
pub fn render_discussion_tree(
frame: &mut Frame<'_>,
discussions: &[DiscussionNode],
state: &DiscussionTreeState,
area: Rect,
colors: &DiscussionTreeColors,
clock: &dyn Clock,
) -> u16 {
if discussions.is_empty() || area.height == 0 || area.width < 15 {
return 0;
}
let max_x = area.x.saturating_add(area.width);
let mut y = area.y;
let y_max = area.y.saturating_add(area.height);
// Pre-compute all visual rows to support scroll offset.
let rows = compute_visual_rows_with_clock(
discussions,
state,
max_x.saturating_sub(area.x) as usize,
clock,
);
// Apply scroll offset.
let visible_rows = rows
.iter()
.skip(state.scroll_offset)
.take(area.height as usize);
for row in visible_rows {
if y >= y_max {
break;
}
match row {
VisualRow::DiscussionHeader {
disc_idx,
expanded,
summary,
preview,
} => {
let is_selected = *disc_idx == state.selected;
// Background fill for selected.
if is_selected {
frame.draw_rect_filled(
Rect::new(area.x, y, area.width, 1),
Cell {
fg: colors.selected_fg,
bg: colors.selected_bg,
..Cell::default()
},
);
}
let style = if is_selected {
Cell {
fg: colors.selected_fg,
bg: colors.selected_bg,
..Cell::default()
}
} else {
Cell {
fg: colors.author_fg,
..Cell::default()
}
};
let indicator = if *expanded { "v " } else { "> " };
let mut x = frame.print_text_clipped(area.x, y, indicator, style, max_x);
x = frame.print_text_clipped(x, y, summary, style, max_x);
// Show preview on same line for collapsed.
if !expanded && !preview.is_empty() {
let preview_style = if is_selected {
style
} else {
Cell {
fg: colors.timestamp_fg,
..Cell::default()
}
};
x = frame.print_text_clipped(x, y, " - ", preview_style, max_x);
let _ = frame.print_text_clipped(x, y, preview, preview_style, max_x);
}
y += 1;
}
VisualRow::NoteHeader {
author,
relative_time,
is_system,
..
} => {
let style = if *is_system {
Cell {
fg: colors.system_fg,
..Cell::default()
}
} else {
Cell {
fg: colors.author_fg,
..Cell::default()
}
};
let guide_style = Cell {
fg: colors.guide_fg,
..Cell::default()
};
let indent_x = area.x.saturating_add(INDENT);
let mut x = frame.print_text_clipped(area.x, y, " | ", guide_style, max_x);
x = frame.print_text_clipped(x.max(indent_x), y, author, style, max_x);
let time_style = Cell {
fg: colors.timestamp_fg,
..Cell::default()
};
x = frame.print_text_clipped(x, y, " · ", time_style, max_x);
let _ = frame.print_text_clipped(x, y, relative_time, time_style, max_x);
y += 1;
}
VisualRow::DiffPath { file_path, line } => {
let guide_style = Cell {
fg: colors.guide_fg,
..Cell::default()
};
let path_style = Cell {
fg: colors.diff_path_fg,
..Cell::default()
};
let mut x = frame.print_text_clipped(area.x, y, " | ", guide_style, max_x);
let indent_x = area.x.saturating_add(INDENT);
x = x.max(indent_x);
let location = match line {
Some(l) => format!("diff {file_path}:{l}"),
None => format!("diff {file_path}"),
};
let _ = frame.print_text_clipped(x, y, &location, path_style, max_x);
y += 1;
}
VisualRow::BodyLine { text, is_system } => {
let guide_style = Cell {
fg: colors.guide_fg,
..Cell::default()
};
let body_style = if *is_system {
Cell {
fg: colors.system_fg,
..Cell::default()
}
} else {
Cell {
fg: colors.body_fg,
..Cell::default()
}
};
let mut x = frame.print_text_clipped(area.x, y, " | ", guide_style, max_x);
let indent_x = area.x.saturating_add(INDENT);
x = x.max(indent_x);
let _ = frame.print_text_clipped(x, y, text, body_style, max_x);
y += 1;
}
VisualRow::Separator => {
let guide_style = Cell {
fg: colors.guide_fg,
..Cell::default()
};
let _ = frame.print_text_clipped(area.x, y, " |", guide_style, max_x);
y += 1;
}
}
}
y.saturating_sub(area.y)
}
// ---------------------------------------------------------------------------
// Visual row computation
// ---------------------------------------------------------------------------
/// Pre-computed visual row for the discussion tree.
///
/// We flatten the tree into rows to support scroll offset correctly.
#[derive(Debug)]
enum VisualRow {
/// Discussion header (collapsed or expanded).
DiscussionHeader {
disc_idx: usize,
expanded: bool,
summary: String,
preview: String,
},
/// Note author + timestamp line.
NoteHeader {
author: String,
relative_time: String,
is_system: bool,
},
/// Diff note file path line.
DiffPath {
file_path: String,
line: Option<i64>,
},
/// Note body text line.
BodyLine { text: String, is_system: bool },
/// Blank separator between notes.
Separator,
}
/// Maximum body lines shown per note to prevent one huge note from
/// consuming the entire viewport.
const MAX_BODY_LINES: usize = 10;
/// Compute visual rows with relative timestamps from the clock.
fn compute_visual_rows_with_clock(
discussions: &[DiscussionNode],
state: &DiscussionTreeState,
available_width: usize,
clock: &dyn Clock,
) -> Vec<VisualRow> {
let mut rows = Vec::new();
let preview_max = available_width.saturating_sub(40).max(20);
for (idx, disc) in discussions.iter().enumerate() {
let expanded = state.is_expanded(&disc.discussion_id);
rows.push(VisualRow::DiscussionHeader {
disc_idx: idx,
expanded,
summary: disc.summary(),
preview: if expanded {
String::new()
} else {
disc.preview(preview_max)
},
});
if expanded {
for (note_idx, note) in disc.notes.iter().enumerate() {
if note_idx > 0 {
rows.push(VisualRow::Separator);
}
rows.push(VisualRow::NoteHeader {
author: note.author.clone(),
relative_time: format_relative_time(note.created_at, clock),
is_system: note.is_system,
});
if note.is_diff_note
&& let Some(ref path) = note.diff_file_path
{
rows.push(VisualRow::DiffPath {
file_path: path.clone(),
line: note.diff_new_line,
});
}
let sanitized = sanitize_for_terminal(&note.body, UrlPolicy::Strip);
for (line_idx, line) in sanitized.lines().enumerate() {
if line_idx >= MAX_BODY_LINES {
rows.push(VisualRow::BodyLine {
text: "...".to_string(),
is_system: note.is_system,
});
break;
}
rows.push(VisualRow::BodyLine {
text: line.to_string(),
is_system: note.is_system,
});
}
}
}
}
rows
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::clock::FakeClock;
use ftui::render::grapheme_pool::GraphemePool;
macro_rules! with_frame {
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
let mut pool = GraphemePool::new();
let mut $frame = Frame::new($width, $height, &mut pool);
$body
}};
}
fn sample_note(author: &str, body: &str, created_at: i64) -> NoteNode {
NoteNode {
author: author.into(),
body: body.into(),
created_at,
is_system: false,
is_diff_note: false,
diff_file_path: None,
diff_new_line: None,
}
}
fn system_note(body: &str, created_at: i64) -> NoteNode {
NoteNode {
author: "system".into(),
body: body.into(),
created_at,
is_system: true,
is_diff_note: false,
diff_file_path: None,
diff_new_line: None,
}
}
fn diff_note(author: &str, body: &str, path: &str, line: i64, created_at: i64) -> NoteNode {
NoteNode {
author: author.into(),
body: body.into(),
created_at,
is_system: false,
is_diff_note: true,
diff_file_path: Some(path.into()),
diff_new_line: Some(line),
}
}
fn sample_discussions() -> Vec<DiscussionNode> {
vec![
DiscussionNode {
discussion_id: "disc-1".into(),
notes: vec![
sample_note("alice", "This looks good overall", 1_700_000_000_000),
sample_note("bob", "Agreed, but one concern", 1_700_000_060_000),
],
resolvable: false,
resolved: false,
},
DiscussionNode {
discussion_id: "disc-2".into(),
notes: vec![diff_note(
"charlie",
"This function needs error handling",
"src/auth.rs",
42,
1_700_000_120_000,
)],
resolvable: true,
resolved: true,
},
DiscussionNode {
discussion_id: "disc-3".into(),
notes: vec![system_note("changed the description", 1_700_000_180_000)],
resolvable: false,
resolved: false,
},
]
}
fn test_colors() -> DiscussionTreeColors {
DiscussionTreeColors {
author_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
timestamp_fg: PackedRgba::rgb(0x87, 0x87, 0x80),
body_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
system_fg: PackedRgba::rgb(0x6F, 0x6E, 0x69),
diff_path_fg: PackedRgba::rgb(0x87, 0x96, 0x6B),
resolved_fg: PackedRgba::rgb(0x87, 0x96, 0x6B),
guide_fg: PackedRgba::rgb(0x40, 0x40, 0x3C),
selected_fg: PackedRgba::rgb(0x10, 0x0F, 0x0F),
selected_bg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
expand_fg: PackedRgba::rgb(0xDA, 0x70, 0x2C),
}
}
// Clock set to 1h after the last sample note.
fn test_clock() -> FakeClock {
FakeClock::from_ms(1_700_000_180_000 + 3_600_000)
}
#[test]
fn test_format_relative_time_just_now() {
let clock = FakeClock::from_ms(1_000_000);
assert_eq!(format_relative_time(1_000_000, &clock), "just now");
assert_eq!(format_relative_time(999_990, &clock), "just now");
}
#[test]
fn test_format_relative_time_minutes() {
let clock = FakeClock::from_ms(1_000_000 + 5 * 60 * 1_000);
assert_eq!(format_relative_time(1_000_000, &clock), "5m ago");
}
#[test]
fn test_format_relative_time_hours() {
let clock = FakeClock::from_ms(1_000_000 + 3 * 3_600 * 1_000);
assert_eq!(format_relative_time(1_000_000, &clock), "3h ago");
}
#[test]
fn test_format_relative_time_days() {
let clock = FakeClock::from_ms(1_000_000 + 2 * 86_400 * 1_000);
assert_eq!(format_relative_time(1_000_000, &clock), "2d ago");
}
#[test]
fn test_format_relative_time_weeks() {
let clock = FakeClock::from_ms(1_000_000 + 14 * 86_400 * 1_000);
assert_eq!(format_relative_time(1_000_000, &clock), "2w ago");
}
#[test]
fn test_format_relative_time_months() {
let clock = FakeClock::from_ms(1_000_000 + 60 * 86_400 * 1_000);
assert_eq!(format_relative_time(1_000_000, &clock), "2mo ago");
}
#[test]
fn test_discussion_node_summary() {
let disc = DiscussionNode {
discussion_id: "d1".into(),
notes: vec![
sample_note("alice", "body", 0),
sample_note("bob", "reply", 1000),
],
resolvable: false,
resolved: false,
};
assert_eq!(disc.summary(), "alice (2 notes)");
}
#[test]
fn test_discussion_node_summary_single() {
let disc = DiscussionNode {
discussion_id: "d1".into(),
notes: vec![sample_note("alice", "body", 0)],
resolvable: false,
resolved: false,
};
assert_eq!(disc.summary(), "alice");
}
#[test]
fn test_discussion_node_summary_resolved() {
let disc = DiscussionNode {
discussion_id: "d1".into(),
notes: vec![sample_note("alice", "body", 0)],
resolvable: true,
resolved: true,
};
assert_eq!(disc.summary(), "alice [resolved]");
}
#[test]
fn test_discussion_node_preview() {
let disc = DiscussionNode {
discussion_id: "d1".into(),
notes: vec![sample_note("alice", "First line\nSecond line", 0)],
resolvable: false,
resolved: false,
};
assert_eq!(disc.preview(50), "First line");
}
#[test]
fn test_discussion_tree_state_navigation() {
let mut state = DiscussionTreeState::default();
assert_eq!(state.selected, 0);
state.select_next(3);
assert_eq!(state.selected, 1);
state.select_next(3);
assert_eq!(state.selected, 2);
state.select_next(3);
assert_eq!(state.selected, 2);
state.select_prev();
assert_eq!(state.selected, 1);
state.select_prev();
assert_eq!(state.selected, 0);
state.select_prev();
assert_eq!(state.selected, 0);
}
#[test]
fn test_discussion_tree_state_toggle() {
let discussions = sample_discussions();
let mut state = DiscussionTreeState::default();
assert!(!state.is_expanded("disc-1"));
state.toggle_selected(&discussions);
assert!(state.is_expanded("disc-1"));
state.toggle_selected(&discussions);
assert!(!state.is_expanded("disc-1"));
}
#[test]
fn test_render_discussion_tree_collapsed_no_panic() {
with_frame!(80, 20, |frame| {
let discussions = sample_discussions();
let state = DiscussionTreeState::default();
let clock = test_clock();
let rows = render_discussion_tree(
&mut frame,
&discussions,
&state,
Rect::new(0, 0, 80, 20),
&test_colors(),
&clock,
);
// 3 discussions, all collapsed = 3 rows.
assert_eq!(rows, 3);
});
}
#[test]
fn test_render_discussion_tree_expanded_no_panic() {
with_frame!(80, 30, |frame| {
let discussions = sample_discussions();
let mut state = DiscussionTreeState::default();
state.expanded.insert("disc-1".into());
let clock = test_clock();
let rows = render_discussion_tree(
&mut frame,
&discussions,
&state,
Rect::new(0, 0, 80, 30),
&test_colors(),
&clock,
);
// disc-1 expanded: header + 2 notes (each: header + body line) + separator between
// = 1 + (1+1) + 1 + (1+1) = 6 rows from disc-1
// disc-2 collapsed: 1 row
// disc-3 collapsed: 1 row
// Total: 8
assert!(rows >= 6); // At least disc-1 content + 2 collapsed.
});
}
#[test]
fn test_render_discussion_tree_empty() {
with_frame!(80, 20, |frame| {
let state = DiscussionTreeState::default();
let clock = test_clock();
let rows = render_discussion_tree(
&mut frame,
&[],
&state,
Rect::new(0, 0, 80, 20),
&test_colors(),
&clock,
);
assert_eq!(rows, 0);
});
}
#[test]
fn test_render_discussion_tree_tiny_area() {
with_frame!(10, 2, |frame| {
let discussions = sample_discussions();
let state = DiscussionTreeState::default();
let clock = test_clock();
let rows = render_discussion_tree(
&mut frame,
&discussions,
&state,
Rect::new(0, 0, 10, 2),
&test_colors(),
&clock,
);
// Too narrow (< 15), should bail.
assert_eq!(rows, 0);
});
}
#[test]
fn test_render_discussion_tree_with_diff_note() {
with_frame!(80, 30, |frame| {
let discussions = vec![DiscussionNode {
discussion_id: "diff-disc".into(),
notes: vec![diff_note(
"reviewer",
"Add error handling here",
"src/main.rs",
42,
1_700_000_000_000,
)],
resolvable: true,
resolved: false,
}];
let mut state = DiscussionTreeState::default();
state.expanded.insert("diff-disc".into());
let clock = test_clock();
let rows = render_discussion_tree(
&mut frame,
&discussions,
&state,
Rect::new(0, 0, 80, 30),
&test_colors(),
&clock,
);
// header + note header + diff path + body line = 4
assert!(rows >= 3);
});
}
#[test]
fn test_render_discussion_tree_system_note() {
with_frame!(80, 20, |frame| {
let discussions = vec![DiscussionNode {
discussion_id: "sys-disc".into(),
notes: vec![system_note("changed the description", 1_700_000_000_000)],
resolvable: false,
resolved: false,
}];
let mut state = DiscussionTreeState::default();
state.expanded.insert("sys-disc".into());
let clock = test_clock();
let rows = render_discussion_tree(
&mut frame,
&discussions,
&state,
Rect::new(0, 0, 80, 20),
&test_colors(),
&clock,
);
assert!(rows >= 2);
});
}
#[test]
fn test_compute_visual_rows_collapsed() {
let discussions = sample_discussions();
let state = DiscussionTreeState::default();
let clock = test_clock();
let rows = compute_visual_rows_with_clock(&discussions, &state, 80, &clock);
// 3 collapsed headers.
assert_eq!(rows.len(), 3);
assert!(matches!(
rows[0],
VisualRow::DiscussionHeader {
expanded: false,
..
}
));
}
#[test]
fn test_compute_visual_rows_expanded() {
let discussions = sample_discussions();
let mut state = DiscussionTreeState::default();
state.expanded.insert("disc-1".into());
let clock = test_clock();
let rows = compute_visual_rows_with_clock(&discussions, &state, 80, &clock);
// disc-1: header + note1 (header + body) + separator + note2 (header + body) = 6
// disc-2: 1 header
// disc-3: 1 header
// Total: 8
assert!(rows.len() >= 6);
assert!(matches!(
rows[0],
VisualRow::DiscussionHeader { expanded: true, .. }
));
}
#[test]
fn test_long_body_truncation() {
let long_body = (0..20)
.map(|i| format!("Line {i} of a very long discussion note"))
.collect::<Vec<_>>()
.join("\n");
let discussions = vec![DiscussionNode {
discussion_id: "long".into(),
notes: vec![sample_note("alice", &long_body, 1_700_000_000_000)],
resolvable: false,
resolved: false,
}];
let mut state = DiscussionTreeState::default();
state.expanded.insert("long".into());
let clock = test_clock();
let rows = compute_visual_rows_with_clock(&discussions, &state, 80, &clock);
// Header + note header + MAX_BODY_LINES + 1 ("...") = 1 + 1 + 10 + 1 = 13
let body_lines: Vec<_> = rows
.iter()
.filter(|r| matches!(r, VisualRow::BodyLine { .. }))
.collect();
// Should cap at MAX_BODY_LINES + 1 (for the "..." truncation line).
assert!(body_lines.len() <= MAX_BODY_LINES + 1);
}
#[test]
fn test_preview_multibyte_utf8_no_panic() {
// Emoji are 4 bytes each. Truncating at a byte boundary that falls
// inside a multi-byte char must not panic.
let disc = DiscussionNode {
discussion_id: "d-utf8".into(),
notes: vec![sample_note(
"alice",
"Hello 🌍🌎🌏 world of emoji 🎉🎊🎈",
0,
)],
resolvable: false,
resolved: false,
};
// max_chars=10 would land inside the first emoji's bytes.
let preview = disc.preview(10);
assert!(preview.ends_with("..."));
assert!(preview.len() <= 20); // char-bounded + "..."
// Edge: max_chars smaller than a single multi-byte char.
let disc2 = DiscussionNode {
discussion_id: "d-utf8-2".into(),
notes: vec![sample_note("bob", "🌍🌎🌏", 0)],
resolvable: false,
resolved: false,
};
let preview2 = disc2.preview(3);
assert!(preview2.ends_with("..."));
}
}

View File

@@ -5,6 +5,8 @@
//! no side effects.
mod breadcrumb;
pub mod cross_ref;
pub mod discussion_tree;
pub mod entity_table;
mod error_toast;
pub mod filter_bar;
@@ -13,6 +15,11 @@ mod loading;
mod status_bar;
pub use breadcrumb::render_breadcrumb;
pub use cross_ref::{CrossRef, CrossRefColors, CrossRefKind, CrossRefState, render_cross_refs};
pub use discussion_tree::{
DiscussionNode, DiscussionTreeColors, DiscussionTreeState, NoteNode, format_relative_time,
render_discussion_tree,
};
pub use entity_table::{ColumnDef, EntityTableState, TableColors, TableRow, render_entity_table};
pub use error_toast::render_error_toast;
pub use filter_bar::{FilterBarColors, FilterBarState, render_filter_bar};

View File

@@ -0,0 +1,626 @@
#![allow(dead_code)] // Phase 2: consumed by view/mod.rs screen dispatch
//! Issue detail screen view.
//!
//! Composes metadata header, description, discussion tree, and
//! cross-references into a scrollable detail layout. Supports
//! progressive hydration: metadata renders immediately while
//! discussions load async in Phase 2.
use ftui::core::geometry::Rect;
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::clock::Clock;
use crate::safety::{UrlPolicy, sanitize_for_terminal};
use crate::state::issue_detail::{DetailSection, IssueDetailState, IssueMetadata};
use crate::view::common::cross_ref::{CrossRefColors, render_cross_refs};
use crate::view::common::discussion_tree::{DiscussionTreeColors, render_discussion_tree};
// ---------------------------------------------------------------------------
// Colors (Flexoki palette — will use injected Theme in a later phase)
// ---------------------------------------------------------------------------
const TEXT: PackedRgba = PackedRgba::rgb(0xCE, 0xCD, 0xC3); // tx
const TEXT_MUTED: PackedRgba = PackedRgba::rgb(0x87, 0x87, 0x80); // tx-2
const ACCENT: PackedRgba = PackedRgba::rgb(0xDA, 0x70, 0x2C); // orange
const GREEN: PackedRgba = PackedRgba::rgb(0x87, 0x9A, 0x39); // green
const RED: PackedRgba = PackedRgba::rgb(0xAF, 0x3A, 0x29); // red
const CYAN: PackedRgba = PackedRgba::rgb(0x3A, 0xA9, 0x9F); // cyan
const BG_SURFACE: PackedRgba = PackedRgba::rgb(0x28, 0x28, 0x24); // bg-2
const BORDER: PackedRgba = PackedRgba::rgb(0x87, 0x87, 0x80); // tx-2
const SELECTED_FG: PackedRgba = PackedRgba::rgb(0x10, 0x0F, 0x0F); // bg
const SELECTED_BG: PackedRgba = PackedRgba::rgb(0xCE, 0xCD, 0xC3); // tx
// ---------------------------------------------------------------------------
// Color constructors
// ---------------------------------------------------------------------------
fn discussion_colors() -> DiscussionTreeColors {
DiscussionTreeColors {
author_fg: CYAN,
timestamp_fg: TEXT_MUTED,
body_fg: TEXT,
system_fg: TEXT_MUTED,
diff_path_fg: GREEN,
resolved_fg: TEXT_MUTED,
guide_fg: BORDER,
selected_fg: SELECTED_FG,
selected_bg: SELECTED_BG,
expand_fg: ACCENT,
}
}
fn cross_ref_colors() -> CrossRefColors {
CrossRefColors {
kind_fg: ACCENT,
label_fg: TEXT,
muted_fg: TEXT_MUTED,
selected_fg: SELECTED_FG,
selected_bg: SELECTED_BG,
}
}
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
/// Render the full issue detail screen.
///
/// Layout:
/// ```text
/// Row 0: #42 Fix authentication flow (title bar)
/// Row 1: opened | alice | backend, security (metadata row)
/// Row 2: Milestone: v1.0 | Due: 2026-03-01 (optional)
/// Row 3: ─────────────────────────────────── (separator)
/// Row 4..N: Description text... (scrollable)
/// ─────────────────────────────────── (separator)
/// Discussions (3) (section header)
/// ▶ alice: Fixed the login flow... (collapsed)
/// ▼ bob: I think we should also... (expanded)
/// bob: body line 1...
/// ─────────────────────────────────── (separator)
/// Cross References (section header)
/// [MR] !10 Fix authentication MR
/// ```
pub fn render_issue_detail(
frame: &mut Frame<'_>,
state: &IssueDetailState,
area: Rect,
clock: &dyn Clock,
) {
if area.height < 3 || area.width < 10 {
return;
}
let Some(ref meta) = state.metadata else {
// No metadata yet — the loading spinner handles this.
return;
};
let max_x = area.x.saturating_add(area.width);
let mut y = area.y;
// --- Title bar ---
y = render_title_bar(frame, meta, area.x, y, max_x);
// --- Metadata row ---
y = render_metadata_row(frame, meta, area.x, y, max_x);
// --- Optional milestone / due date row ---
if meta.milestone.is_some() || meta.due_date.is_some() {
y = render_milestone_row(frame, meta, area.x, y, max_x);
}
// --- Separator ---
y = render_separator(frame, area.x, y, area.width);
let bottom = area.y.saturating_add(area.height);
if y >= bottom {
return;
}
// Remaining space is split between description, discussions, and cross-refs.
let remaining = bottom.saturating_sub(y);
// Compute section heights based on content.
let desc_lines = count_description_lines(meta, area.width);
let disc_count = state.discussions.len();
let xref_count = state.cross_refs.len();
let (desc_h, disc_h, xref_h) = allocate_sections(remaining, desc_lines, disc_count, xref_count);
// --- Description section ---
if desc_h > 0 {
let desc_area = Rect::new(area.x, y, area.width, desc_h);
let is_focused = state.active_section == DetailSection::Description;
render_description(frame, meta, state.description_scroll, desc_area, is_focused);
y += desc_h;
}
// --- Separator before discussions ---
if (disc_h > 0 || xref_h > 0) && y < bottom {
y = render_separator(frame, area.x, y, area.width);
}
// --- Discussions section ---
if disc_h > 0 && y < bottom {
let header_h = 1;
let is_focused = state.active_section == DetailSection::Discussions;
// Section header.
render_section_header(
frame,
&format!("Discussions ({})", state.discussions.len()),
area.x,
y,
max_x,
is_focused,
);
y += header_h;
if !state.discussions_loaded {
// Still loading.
let style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let _ = frame.print_text_clipped(area.x + 1, y, "Loading discussions...", style, max_x);
y += 1;
} else if state.discussions.is_empty() {
let style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let _ = frame.print_text_clipped(area.x + 1, y, "No discussions", style, max_x);
y += 1;
} else {
let tree_height = disc_h.saturating_sub(header_h);
if tree_height > 0 {
let tree_area = Rect::new(area.x, y, area.width, tree_height);
let rendered = render_discussion_tree(
frame,
&state.discussions,
&state.tree_state,
tree_area,
&discussion_colors(),
clock,
);
y += rendered;
}
}
}
// --- Separator before cross-refs ---
if xref_h > 0 && y < bottom {
y = render_separator(frame, area.x, y, area.width);
}
// --- Cross-references section ---
if xref_h > 0 && y < bottom {
let is_focused = state.active_section == DetailSection::CrossRefs;
render_section_header(
frame,
&format!("Cross References ({})", state.cross_refs.len()),
area.x,
y,
max_x,
is_focused,
);
y += 1;
if state.cross_refs.is_empty() {
let style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let _ = frame.print_text_clipped(area.x + 1, y, "No cross-references", style, max_x);
} else {
let refs_height = xref_h.saturating_sub(1); // minus header
if refs_height > 0 {
let refs_area = Rect::new(area.x, y, area.width, refs_height);
let _ = render_cross_refs(
frame,
&state.cross_refs,
&state.cross_ref_state,
refs_area,
&cross_ref_colors(),
);
}
}
}
}
// ---------------------------------------------------------------------------
// Sub-renderers
// ---------------------------------------------------------------------------
/// Render the issue title bar: `#42 Fix authentication flow`
fn render_title_bar(
frame: &mut Frame<'_>,
meta: &IssueMetadata,
x: u16,
y: u16,
max_x: u16,
) -> u16 {
let iid_text = format!("#{} ", meta.iid);
let iid_style = Cell {
fg: ACCENT,
..Cell::default()
};
let title_style = Cell {
fg: TEXT,
..Cell::default()
};
let cx = frame.print_text_clipped(x, y, &iid_text, iid_style, max_x);
let safe_title = sanitize_for_terminal(&meta.title, UrlPolicy::Strip);
let _ = frame.print_text_clipped(cx, y, &safe_title, title_style, max_x);
y + 1
}
/// Render the metadata row: `opened | alice | backend, security`
fn render_metadata_row(
frame: &mut Frame<'_>,
meta: &IssueMetadata,
x: u16,
y: u16,
max_x: u16,
) -> u16 {
let state_fg = match meta.state.as_str() {
"opened" => GREEN,
"closed" => RED,
_ => TEXT_MUTED,
};
let state_style = Cell {
fg: state_fg,
..Cell::default()
};
let muted_style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let author_style = Cell {
fg: CYAN,
..Cell::default()
};
let mut cx = frame.print_text_clipped(x, y, &meta.state, state_style, max_x);
cx = frame.print_text_clipped(cx, y, " | ", muted_style, max_x);
cx = frame.print_text_clipped(cx, y, &meta.author, author_style, max_x);
if !meta.labels.is_empty() {
cx = frame.print_text_clipped(cx, y, " | ", muted_style, max_x);
let labels_text = meta.labels.join(", ");
let _ = frame.print_text_clipped(cx, y, &labels_text, muted_style, max_x);
}
if !meta.assignees.is_empty() {
cx = frame.print_text_clipped(cx, y, " | ", muted_style, max_x);
let assignees_text = format!("-> {}", meta.assignees.join(", "));
let _ = frame.print_text_clipped(cx, y, &assignees_text, muted_style, max_x);
}
y + 1
}
/// Render optional milestone / due date row.
fn render_milestone_row(
frame: &mut Frame<'_>,
meta: &IssueMetadata,
x: u16,
y: u16,
max_x: u16,
) -> u16 {
let muted = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let mut cx = x;
if let Some(ref ms) = meta.milestone {
cx = frame.print_text_clipped(cx, y, "Milestone: ", muted, max_x);
let val_style = Cell {
fg: TEXT,
..Cell::default()
};
cx = frame.print_text_clipped(cx, y, ms, val_style, max_x);
}
if let Some(ref due) = meta.due_date {
if cx > x {
cx = frame.print_text_clipped(cx, y, " | ", muted, max_x);
}
cx = frame.print_text_clipped(cx, y, "Due: ", muted, max_x);
let val_style = Cell {
fg: TEXT,
..Cell::default()
};
let _ = frame.print_text_clipped(cx, y, due, val_style, max_x);
}
y + 1
}
/// Render a horizontal separator line.
fn render_separator(frame: &mut Frame<'_>, x: u16, y: u16, width: u16) -> u16 {
let sep_style = Cell {
fg: BORDER,
..Cell::default()
};
let line: String = "\u{2500}".repeat(width as usize);
let _ = frame.print_text_clipped(x, y, &line, sep_style, x.saturating_add(width));
y + 1
}
/// Render a section header with focus indicator.
fn render_section_header(
frame: &mut Frame<'_>,
label: &str,
x: u16,
y: u16,
max_x: u16,
is_focused: bool,
) {
if is_focused {
let style = Cell {
fg: SELECTED_FG,
bg: SELECTED_BG,
..Cell::default()
};
// Fill the row with selected background.
frame.draw_rect_filled(Rect::new(x, y, max_x.saturating_sub(x), 1), style);
let _ = frame.print_text_clipped(x, y, label, style, max_x);
} else {
let style = Cell {
fg: ACCENT,
..Cell::default()
};
let _ = frame.print_text_clipped(x, y, label, style, max_x);
}
}
/// Render the description section.
fn render_description(
frame: &mut Frame<'_>,
meta: &IssueMetadata,
scroll: usize,
area: Rect,
_is_focused: bool,
) {
let safe_desc = sanitize_for_terminal(&meta.description, UrlPolicy::Strip);
let lines: Vec<&str> = safe_desc.lines().collect();
let text_style = Cell {
fg: TEXT,
..Cell::default()
};
let max_x = area.x.saturating_add(area.width);
for (i, line) in lines
.iter()
.skip(scroll)
.take(area.height as usize)
.enumerate()
{
let y = area.y + i as u16;
let _ = frame.print_text_clipped(area.x, y, line, text_style, max_x);
}
}
/// Count the number of visible description lines for layout allocation.
fn count_description_lines(meta: &IssueMetadata, _width: u16) -> usize {
if meta.description.is_empty() {
return 0;
}
// Rough estimate: count newlines. Proper word-wrap would need unicode width.
meta.description.lines().count().max(1)
}
/// Allocate vertical space between description, discussions, and cross-refs.
///
/// Priority: description gets min(content, 40%), discussions get most of the
/// remaining space, cross-refs get a fixed portion at the bottom.
fn allocate_sections(
available: u16,
desc_lines: usize,
_disc_count: usize,
xref_count: usize,
) -> (u16, u16, u16) {
if available == 0 {
return (0, 0, 0);
}
let total = available as usize;
// Cross-refs: 1 header + count, max 25% of space.
let xref_need = if xref_count > 0 {
(1 + xref_count).min(total / 4)
} else {
0
};
let after_xref = total.saturating_sub(xref_need);
// Description: up to 40% of remaining, but at least the content lines.
let desc_max = after_xref * 2 / 5;
let desc_alloc = desc_lines.min(desc_max).min(after_xref);
// Discussions: everything else.
let disc_alloc = after_xref.saturating_sub(desc_alloc);
(desc_alloc as u16, disc_alloc as u16, xref_need as u16)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::clock::FakeClock;
use crate::message::EntityKey;
use crate::state::issue_detail::{IssueDetailData, IssueMetadata};
use crate::view::common::cross_ref::{CrossRef, CrossRefKind};
use crate::view::common::discussion_tree::{DiscussionNode, NoteNode};
use ftui::render::grapheme_pool::GraphemePool;
macro_rules! with_frame {
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
let mut pool = GraphemePool::new();
let mut $frame = Frame::new($width, $height, &mut pool);
$body
}};
}
fn sample_metadata() -> IssueMetadata {
IssueMetadata {
iid: 42,
project_path: "group/project".into(),
title: "Fix authentication flow".into(),
description: "The login page has a bug.\nSteps to reproduce:\n1. Go to /login\n2. Enter credentials\n3. Click submit".into(),
state: "opened".into(),
author: "alice".into(),
assignees: vec!["bob".into()],
labels: vec!["backend".into(), "security".into()],
milestone: Some("v1.0".into()),
due_date: Some("2026-03-01".into()),
created_at: 1_700_000_000_000,
updated_at: 1_700_000_060_000,
web_url: "https://gitlab.com/group/project/-/issues/42".into(),
discussion_count: 2,
}
}
fn sample_state_with_metadata() -> IssueDetailState {
let mut state = IssueDetailState::default();
state.load_new(EntityKey::issue(1, 42));
state.apply_metadata(IssueDetailData {
metadata: sample_metadata(),
cross_refs: vec![CrossRef {
kind: CrossRefKind::ClosingMr,
entity_key: EntityKey::mr(1, 10),
label: "Fix auth MR".into(),
navigable: true,
}],
});
state
}
#[test]
fn test_render_issue_detail_no_metadata_no_panic() {
with_frame!(80, 24, |frame| {
let state = IssueDetailState::default();
let clock = FakeClock::from_ms(1_700_000_000_000);
render_issue_detail(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
});
}
#[test]
fn test_render_issue_detail_with_metadata_no_panic() {
with_frame!(80, 24, |frame| {
let state = sample_state_with_metadata();
let clock = FakeClock::from_ms(1_700_000_060_000);
render_issue_detail(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
});
}
#[test]
fn test_render_issue_detail_tiny_area() {
with_frame!(5, 2, |frame| {
let state = sample_state_with_metadata();
let clock = FakeClock::from_ms(1_700_000_060_000);
render_issue_detail(&mut frame, &state, Rect::new(0, 0, 5, 2), &clock);
// Should bail early, no panic.
});
}
#[test]
fn test_render_issue_detail_with_discussions() {
with_frame!(80, 40, |frame| {
let mut state = sample_state_with_metadata();
state.apply_discussions(vec![DiscussionNode {
discussion_id: "d1".into(),
notes: vec![NoteNode {
author: "alice".into(),
body: "I found the bug".into(),
created_at: 1_700_000_000_000,
is_system: false,
is_diff_note: false,
diff_file_path: None,
diff_new_line: None,
}],
resolvable: false,
resolved: false,
}]);
let clock = FakeClock::from_ms(1_700_000_060_000);
render_issue_detail(&mut frame, &state, Rect::new(0, 0, 80, 40), &clock);
});
}
#[test]
fn test_render_issue_detail_discussions_loading() {
with_frame!(80, 24, |frame| {
let state = sample_state_with_metadata();
// discussions_loaded is false by default after load_new.
assert!(!state.discussions_loaded);
let clock = FakeClock::from_ms(1_700_000_060_000);
render_issue_detail(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
});
}
#[test]
fn test_render_issue_detail_narrow_terminal() {
with_frame!(30, 10, |frame| {
let state = sample_state_with_metadata();
let clock = FakeClock::from_ms(1_700_000_060_000);
render_issue_detail(&mut frame, &state, Rect::new(0, 0, 30, 10), &clock);
});
}
#[test]
fn test_allocate_sections_empty() {
assert_eq!(allocate_sections(0, 5, 3, 2), (0, 0, 0));
}
#[test]
fn test_allocate_sections_balanced() {
let (d, disc, x) = allocate_sections(20, 5, 3, 2);
assert!(d > 0);
assert!(disc > 0);
assert!(x > 0);
assert_eq!(d + disc + x, 20);
}
#[test]
fn test_allocate_sections_no_xrefs() {
let (d, disc, x) = allocate_sections(20, 5, 3, 0);
assert_eq!(x, 0);
assert_eq!(d + disc, 20);
}
#[test]
fn test_allocate_sections_no_discussions() {
let (d, disc, x) = allocate_sections(20, 5, 0, 2);
assert!(d > 0);
assert_eq!(d + disc + x, 20);
}
#[test]
fn test_count_description_lines() {
let meta = sample_metadata();
let lines = count_description_lines(&meta, 80);
assert_eq!(lines, 5); // 5 lines in the sample description
}
#[test]
fn test_count_description_lines_empty() {
let mut meta = sample_metadata();
meta.description = String::new();
assert_eq!(count_description_lines(&meta, 80), 0);
}
}

View File

@@ -8,7 +8,9 @@
pub mod common;
pub mod dashboard;
pub mod issue_detail;
pub mod issue_list;
pub mod mr_detail;
pub mod mr_list;
use ftui::layout::{Constraint, Flex};
@@ -22,7 +24,9 @@ use common::{
render_breadcrumb, render_error_toast, render_help_overlay, render_loading, render_status_bar,
};
use dashboard::render_dashboard;
use issue_detail::render_issue_detail;
use issue_list::render_issue_list;
use mr_detail::render_mr_detail;
use mr_list::render_mr_list;
// ---------------------------------------------------------------------------
@@ -93,6 +97,10 @@ pub fn render_screen(frame: &mut Frame<'_>, app: &LoreApp) {
render_issue_list(frame, &app.state.issue_list, content_area);
} else if screen == &Screen::MrList {
render_mr_list(frame, &app.state.mr_list, content_area);
} else if matches!(screen, Screen::IssueDetail(_)) {
render_issue_detail(frame, &app.state.issue_detail, content_area, &*app.clock);
} else if matches!(screen, Screen::MrDetail(_)) {
render_mr_detail(frame, &app.state.mr_detail, content_area, &*app.clock);
}
// --- Status bar ---

View File

@@ -0,0 +1,635 @@
#![allow(dead_code)] // Phase 2: consumed by view/mod.rs screen dispatch
//! Merge request detail screen view.
//!
//! Composes metadata header, tab bar (Overview / Files / Discussions),
//! and tab content. Supports progressive hydration: metadata + file
//! changes render immediately while discussions load async.
use ftui::core::geometry::Rect;
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::clock::Clock;
use crate::safety::{UrlPolicy, sanitize_for_terminal};
use crate::state::mr_detail::{FileChangeType, MrDetailState, MrMetadata, MrTab};
use crate::view::common::cross_ref::{CrossRefColors, render_cross_refs};
use crate::view::common::discussion_tree::{DiscussionTreeColors, render_discussion_tree};
// ---------------------------------------------------------------------------
// Colors (Flexoki palette)
// ---------------------------------------------------------------------------
const TEXT: PackedRgba = PackedRgba::rgb(0xCE, 0xCD, 0xC3);
const TEXT_MUTED: PackedRgba = PackedRgba::rgb(0x87, 0x87, 0x80);
const ACCENT: PackedRgba = PackedRgba::rgb(0xDA, 0x70, 0x2C);
const GREEN: PackedRgba = PackedRgba::rgb(0x87, 0x9A, 0x39);
const RED: PackedRgba = PackedRgba::rgb(0xAF, 0x3A, 0x29);
const CYAN: PackedRgba = PackedRgba::rgb(0x3A, 0xA9, 0x9F);
const YELLOW: PackedRgba = PackedRgba::rgb(0xD0, 0xA2, 0x15);
const BORDER: PackedRgba = PackedRgba::rgb(0x87, 0x87, 0x80);
const SELECTED_FG: PackedRgba = PackedRgba::rgb(0x10, 0x0F, 0x0F);
const SELECTED_BG: PackedRgba = PackedRgba::rgb(0xCE, 0xCD, 0xC3);
// ---------------------------------------------------------------------------
// Color constructors
// ---------------------------------------------------------------------------
fn discussion_colors() -> DiscussionTreeColors {
DiscussionTreeColors {
author_fg: CYAN,
timestamp_fg: TEXT_MUTED,
body_fg: TEXT,
system_fg: TEXT_MUTED,
diff_path_fg: GREEN,
resolved_fg: TEXT_MUTED,
guide_fg: BORDER,
selected_fg: SELECTED_FG,
selected_bg: SELECTED_BG,
expand_fg: ACCENT,
}
}
fn cross_ref_colors() -> CrossRefColors {
CrossRefColors {
kind_fg: ACCENT,
label_fg: TEXT,
muted_fg: TEXT_MUTED,
selected_fg: SELECTED_FG,
selected_bg: SELECTED_BG,
}
}
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
/// Render the full MR detail screen.
///
/// Layout:
/// ```text
/// Row 0: !10 Fix auth flow (title bar)
/// Row 1: opened | alice | fix-auth -> main (metadata row)
/// Row 2: [Overview] [Files (3)] [Discussions] (tab bar)
/// Row 3: ──────────────────────────────────── (separator)
/// Row 4..N: Tab-specific content
/// ```
pub fn render_mr_detail(
frame: &mut Frame<'_>,
state: &MrDetailState,
area: Rect,
clock: &dyn Clock,
) {
if area.height < 4 || area.width < 10 {
return;
}
let Some(ref meta) = state.metadata else {
return;
};
let max_x = area.x.saturating_add(area.width);
let mut y = area.y;
// --- Title bar ---
y = render_title_bar(frame, meta, area.x, y, max_x);
// --- Metadata row ---
y = render_metadata_row(frame, meta, area.x, y, max_x);
// --- Tab bar ---
y = render_tab_bar(frame, state, area.x, y, max_x);
// --- Separator ---
y = render_separator(frame, area.x, y, area.width);
let bottom = area.y.saturating_add(area.height);
if y >= bottom {
return;
}
let content_area = Rect::new(area.x, y, area.width, bottom.saturating_sub(y));
match state.active_tab {
MrTab::Overview => render_overview_tab(frame, state, meta, content_area, clock),
MrTab::Files => render_files_tab(frame, state, content_area),
MrTab::Discussions => render_discussions_tab(frame, state, content_area, clock),
}
}
// ---------------------------------------------------------------------------
// Sub-renderers
// ---------------------------------------------------------------------------
/// Render `!10 Fix auth flow` (or `!10 [Draft] Fix auth flow`).
fn render_title_bar(frame: &mut Frame<'_>, meta: &MrMetadata, x: u16, y: u16, max_x: u16) -> u16 {
let iid_text = format!("!{} ", meta.iid);
let iid_style = Cell {
fg: ACCENT,
..Cell::default()
};
let mut cx = frame.print_text_clipped(x, y, &iid_text, iid_style, max_x);
if meta.draft {
let draft_style = Cell {
fg: YELLOW,
..Cell::default()
};
cx = frame.print_text_clipped(cx, y, "[Draft] ", draft_style, max_x);
}
let title_style = Cell {
fg: TEXT,
..Cell::default()
};
let safe_title = sanitize_for_terminal(&meta.title, UrlPolicy::Strip);
let _ = frame.print_text_clipped(cx, y, &safe_title, title_style, max_x);
y + 1
}
/// Render `opened | alice | fix-auth -> main | mergeable`.
fn render_metadata_row(
frame: &mut Frame<'_>,
meta: &MrMetadata,
x: u16,
y: u16,
max_x: u16,
) -> u16 {
let state_fg = match meta.state.as_str() {
"opened" => GREEN,
"merged" => CYAN,
"closed" => RED,
_ => TEXT_MUTED,
};
let state_style = Cell {
fg: state_fg,
..Cell::default()
};
let muted = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let author_style = Cell {
fg: CYAN,
..Cell::default()
};
let mut cx = frame.print_text_clipped(x, y, &meta.state, state_style, max_x);
cx = frame.print_text_clipped(cx, y, " | ", muted, max_x);
cx = frame.print_text_clipped(cx, y, &meta.author, author_style, max_x);
cx = frame.print_text_clipped(cx, y, " | ", muted, max_x);
let branch_text = format!("{} -> {}", meta.source_branch, meta.target_branch);
cx = frame.print_text_clipped(cx, y, &branch_text, muted, max_x);
if !meta.merge_status.is_empty() {
cx = frame.print_text_clipped(cx, y, " | ", muted, max_x);
let status_fg = if meta.merge_status == "mergeable" {
GREEN
} else {
YELLOW
};
let status_style = Cell {
fg: status_fg,
..Cell::default()
};
let _ = frame.print_text_clipped(cx, y, &meta.merge_status, status_style, max_x);
}
y + 1
}
/// Render tab bar: `[Overview] [Files (3)] [Discussions (2)]`.
fn render_tab_bar(frame: &mut Frame<'_>, state: &MrDetailState, x: u16, y: u16, max_x: u16) -> u16 {
let tabs = [
(MrTab::Overview, "Overview".to_string()),
(
MrTab::Files,
format!("Files ({})", state.file_changes.len()),
),
(
MrTab::Discussions,
format!("Discussions ({})", state.discussions.len()),
),
];
let mut cx = x;
for (tab, label) in &tabs {
if *tab == state.active_tab {
let style = Cell {
fg: SELECTED_FG,
bg: SELECTED_BG,
..Cell::default()
};
let text = format!(" {label} ");
cx = frame.print_text_clipped(cx, y, &text, style, max_x);
} else {
let style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let text = format!(" {label} ");
cx = frame.print_text_clipped(cx, y, &text, style, max_x);
}
// Tab separator.
let sep = Cell {
fg: BORDER,
..Cell::default()
};
cx = frame.print_text_clipped(cx, y, " ", sep, max_x);
}
y + 1
}
/// Render horizontal separator.
fn render_separator(frame: &mut Frame<'_>, x: u16, y: u16, width: u16) -> u16 {
let sep_style = Cell {
fg: BORDER,
..Cell::default()
};
let line: String = "\u{2500}".repeat(width as usize);
let _ = frame.print_text_clipped(x, y, &line, sep_style, x.saturating_add(width));
y + 1
}
// ---------------------------------------------------------------------------
// Tab content renderers
// ---------------------------------------------------------------------------
/// Overview tab: description + cross-references.
fn render_overview_tab(
frame: &mut Frame<'_>,
state: &MrDetailState,
meta: &MrMetadata,
area: Rect,
_clock: &dyn Clock,
) {
let max_x = area.x.saturating_add(area.width);
let mut y = area.y;
let bottom = area.y.saturating_add(area.height);
// --- Description ---
let safe_desc = sanitize_for_terminal(&meta.description, UrlPolicy::Strip);
let lines: Vec<&str> = safe_desc.lines().collect();
let text_style = Cell {
fg: TEXT,
..Cell::default()
};
for line in lines
.iter()
.skip(state.description_scroll)
.take((bottom.saturating_sub(y)) as usize)
{
let _ = frame.print_text_clipped(area.x, y, line, text_style, max_x);
y += 1;
}
if y >= bottom {
return;
}
// --- Separator ---
y = render_separator(frame, area.x, y, area.width);
if y >= bottom {
return;
}
// --- Cross-references ---
if !state.cross_refs.is_empty() {
let header_style = Cell {
fg: ACCENT,
..Cell::default()
};
let header = format!("Cross References ({})", state.cross_refs.len());
let _ = frame.print_text_clipped(area.x, y, &header, header_style, max_x);
y += 1;
if y < bottom {
let refs_area = Rect::new(area.x, y, area.width, bottom.saturating_sub(y));
let _ = render_cross_refs(
frame,
&state.cross_refs,
&state.cross_ref_state,
refs_area,
&cross_ref_colors(),
);
}
}
}
/// Files tab: list of changed files with change type indicators.
fn render_files_tab(frame: &mut Frame<'_>, state: &MrDetailState, area: Rect) {
let max_x = area.x.saturating_add(area.width);
let mut y = area.y;
let bottom = area.y.saturating_add(area.height);
if state.file_changes.is_empty() {
let style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let _ = frame.print_text_clipped(area.x + 1, y, "No file changes", style, max_x);
return;
}
for (i, fc) in state
.file_changes
.iter()
.skip(state.file_scroll)
.take((bottom.saturating_sub(y)) as usize)
.enumerate()
{
let is_selected = i + state.file_scroll == state.file_selected;
let (fg, bg) = if is_selected {
(SELECTED_FG, SELECTED_BG)
} else {
(TEXT, PackedRgba::TRANSPARENT)
};
if is_selected {
let sel_cell = Cell {
fg,
bg,
..Cell::default()
};
frame.draw_rect_filled(Rect::new(area.x, y, area.width, 1), sel_cell);
}
// Change type icon.
let icon_fg = match fc.change_type {
FileChangeType::Added => GREEN,
FileChangeType::Deleted => RED,
FileChangeType::Modified => YELLOW,
FileChangeType::Renamed => CYAN,
};
let icon_style = Cell {
fg: if is_selected { fg } else { icon_fg },
bg,
..Cell::default()
};
let mut cx = frame.print_text_clipped(area.x, y, fc.change_type.icon(), icon_style, max_x);
cx = frame.print_text_clipped(cx, y, " ", icon_style, max_x);
// File path.
let path_style = Cell {
fg,
bg,
..Cell::default()
};
let display_path = if fc.change_type == FileChangeType::Renamed {
if let Some(ref old) = fc.old_path {
format!("{old} -> {}", fc.new_path)
} else {
fc.new_path.clone()
}
} else {
fc.new_path.clone()
};
let _ = frame.print_text_clipped(cx, y, &display_path, path_style, max_x);
y += 1;
}
}
/// Discussions tab: all discussions rendered via the tree widget.
fn render_discussions_tab(
frame: &mut Frame<'_>,
state: &MrDetailState,
area: Rect,
clock: &dyn Clock,
) {
let max_x = area.x.saturating_add(area.width);
if !state.discussions_loaded {
let style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let _ =
frame.print_text_clipped(area.x + 1, area.y, "Loading discussions...", style, max_x);
return;
}
if state.discussions.is_empty() {
let style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let _ = frame.print_text_clipped(area.x + 1, area.y, "No discussions", style, max_x);
return;
}
let _ = render_discussion_tree(
frame,
&state.discussions,
&state.tree_state,
area,
&discussion_colors(),
clock,
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::clock::FakeClock;
use crate::message::EntityKey;
use crate::state::mr_detail::{FileChange, FileChangeType, MrDetailData, MrMetadata, MrTab};
use crate::view::common::cross_ref::{CrossRef, CrossRefKind};
use crate::view::common::discussion_tree::{DiscussionNode, NoteNode};
use ftui::render::grapheme_pool::GraphemePool;
macro_rules! with_frame {
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
let mut pool = GraphemePool::new();
let mut $frame = Frame::new($width, $height, &mut pool);
$body
}};
}
fn sample_mr_metadata() -> MrMetadata {
MrMetadata {
iid: 10,
project_path: "group/project".into(),
title: "Fix authentication flow".into(),
description: "This MR fixes the login bug.\nSee issue #42.".into(),
state: "opened".into(),
draft: false,
author: "alice".into(),
assignees: vec!["bob".into()],
reviewers: vec!["carol".into()],
labels: vec!["backend".into()],
source_branch: "fix-auth".into(),
target_branch: "main".into(),
merge_status: "mergeable".into(),
created_at: 1_700_000_000_000,
updated_at: 1_700_000_060_000,
merged_at: None,
web_url: "https://gitlab.com/group/project/-/merge_requests/10".into(),
discussion_count: 1,
file_change_count: 2,
}
}
fn sample_mr_state() -> MrDetailState {
let mut state = MrDetailState::default();
state.load_new(EntityKey::mr(1, 10));
state.apply_metadata(MrDetailData {
metadata: sample_mr_metadata(),
cross_refs: vec![CrossRef {
kind: CrossRefKind::ClosingMr,
entity_key: EntityKey::issue(1, 42),
label: "Auth bug".into(),
navigable: true,
}],
file_changes: vec![
FileChange {
old_path: None,
new_path: "src/auth.rs".into(),
change_type: FileChangeType::Modified,
},
FileChange {
old_path: None,
new_path: "src/lib.rs".into(),
change_type: FileChangeType::Added,
},
],
});
state
}
#[test]
fn test_render_mr_detail_no_metadata() {
with_frame!(80, 24, |frame| {
let state = MrDetailState::default();
let clock = FakeClock::from_ms(1_700_000_000_000);
render_mr_detail(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
});
}
#[test]
fn test_render_mr_detail_overview_tab() {
with_frame!(80, 24, |frame| {
let state = sample_mr_state();
let clock = FakeClock::from_ms(1_700_000_060_000);
render_mr_detail(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
});
}
#[test]
fn test_render_mr_detail_files_tab() {
with_frame!(80, 24, |frame| {
let mut state = sample_mr_state();
state.active_tab = MrTab::Files;
let clock = FakeClock::from_ms(1_700_000_060_000);
render_mr_detail(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
});
}
#[test]
fn test_render_mr_detail_discussions_tab_loading() {
with_frame!(80, 24, |frame| {
let mut state = sample_mr_state();
state.active_tab = MrTab::Discussions;
let clock = FakeClock::from_ms(1_700_000_060_000);
render_mr_detail(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
});
}
#[test]
fn test_render_mr_detail_discussions_tab_with_data() {
with_frame!(80, 30, |frame| {
let mut state = sample_mr_state();
state.active_tab = MrTab::Discussions;
state.apply_discussions(vec![DiscussionNode {
discussion_id: "d1".into(),
notes: vec![NoteNode {
author: "alice".into(),
body: "Looks good".into(),
created_at: 1_700_000_020_000,
is_system: false,
is_diff_note: true,
diff_file_path: Some("src/auth.rs".into()),
diff_new_line: Some(42),
}],
resolvable: true,
resolved: false,
}]);
let clock = FakeClock::from_ms(1_700_000_060_000);
render_mr_detail(&mut frame, &state, Rect::new(0, 0, 80, 30), &clock);
});
}
#[test]
fn test_render_mr_detail_draft() {
with_frame!(80, 24, |frame| {
let mut state = sample_mr_state();
state.metadata.as_mut().unwrap().draft = true;
let clock = FakeClock::from_ms(1_700_000_060_000);
render_mr_detail(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
});
}
#[test]
fn test_render_mr_detail_tiny_area() {
with_frame!(5, 3, |frame| {
let state = sample_mr_state();
let clock = FakeClock::from_ms(1_700_000_060_000);
render_mr_detail(&mut frame, &state, Rect::new(0, 0, 5, 3), &clock);
});
}
#[test]
fn test_render_mr_detail_narrow_terminal() {
with_frame!(30, 10, |frame| {
let state = sample_mr_state();
let clock = FakeClock::from_ms(1_700_000_060_000);
render_mr_detail(&mut frame, &state, Rect::new(0, 0, 30, 10), &clock);
});
}
#[test]
fn test_render_files_empty() {
with_frame!(80, 24, |frame| {
let mut state = MrDetailState::default();
state.load_new(EntityKey::mr(1, 10));
state.apply_metadata(MrDetailData {
metadata: sample_mr_metadata(),
cross_refs: vec![],
file_changes: vec![],
});
state.active_tab = MrTab::Files;
let clock = FakeClock::from_ms(1_700_000_060_000);
render_mr_detail(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
});
}
#[test]
fn test_render_files_with_rename() {
with_frame!(80, 24, |frame| {
let mut state = MrDetailState::default();
state.load_new(EntityKey::mr(1, 10));
state.apply_metadata(MrDetailData {
metadata: sample_mr_metadata(),
cross_refs: vec![],
file_changes: vec![FileChange {
old_path: Some("src/old.rs".into()),
new_path: "src/new.rs".into(),
change_type: FileChangeType::Renamed,
}],
});
state.active_tab = MrTab::Files;
let clock = FakeClock::from_ms(1_700_000_060_000);
render_mr_detail(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
});
}
}