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 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);
});
}
}