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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user