#![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::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, /// Reviewer usernames. pub reviewers: Vec, /// Label names. pub labels: Vec, /// 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, /// 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, /// 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, pub file_changes: Vec, } // --------------------------------------------------------------------------- // 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 { /// Entity key for the currently displayed MR. pub current_key: Option, /// MR metadata (Phase 1 load). pub metadata: Option, /// File changes (loaded with metadata in Phase 1). pub file_changes: Vec, /// Discussion nodes (Phase 2 async load). pub discussions: Vec, /// Whether discussions have finished loading. pub discussions_loaded: bool, /// Cross-references (loaded with metadata in Phase 1). pub cross_refs: Vec, /// 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) { 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); } }