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.
388 lines
12 KiB
Rust
388 lines
12 KiB
Rust
#![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<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 {
|
|
/// 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);
|
|
}
|
|
}
|