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:
File diff suppressed because it is too large
Load Diff
@@ -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(),
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
410
crates/lore-tui/src/view/common/cross_ref.rs
Normal file
410
crates/lore-tui/src/view/common/cross_ref.rs
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
979
crates/lore-tui/src/view/common/discussion_tree.rs
Normal file
979
crates/lore-tui/src/view/common/discussion_tree.rs
Normal 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(¬e.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("..."));
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
626
crates/lore-tui/src/view/issue_detail.rs
Normal file
626
crates/lore-tui/src/view/issue_detail.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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 ---
|
||||
|
||||
635
crates/lore-tui/src/view/mr_detail.rs
Normal file
635
crates/lore-tui/src/view/mr_detail.rs
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user