feat(tui): Phase 2 Issue List + MR List screens
Implement state, action, and view layers for both list screens: - Issue List: keyset pagination, snapshot fence, filter DSL, label aggregation - MR List: mirrors Issue pattern with draft/reviewer/target branch filters - Migration 027: covering indexes for TUI list screen queries - Updated Msg types to use typed Page structs instead of raw Vec<Row> - 303 tests passing, clippy clean Beads: bd-3ei1, bd-2kr0, bd-3pm2
This commit is contained in:
@@ -1,10 +1,255 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
//! Dashboard screen state.
|
||||
//!
|
||||
//! The dashboard is the home screen — entity counts, per-project sync
|
||||
//! status, recent activity, and the last sync summary.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EntityCounts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Aggregated entity counts from the local database.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct EntityCounts {
|
||||
pub issues_open: u64,
|
||||
pub issues_total: u64,
|
||||
pub mrs_open: u64,
|
||||
pub mrs_total: u64,
|
||||
pub discussions: u64,
|
||||
pub notes_total: u64,
|
||||
/// Percentage of notes that are system-generated (0-100).
|
||||
pub notes_system_pct: u8,
|
||||
pub documents: u64,
|
||||
pub embeddings: u64,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ProjectSyncInfo
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Per-project sync freshness.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProjectSyncInfo {
|
||||
pub path: String,
|
||||
pub minutes_since_sync: u64,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RecentActivityItem
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A recently-updated entity for the activity feed.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RecentActivityItem {
|
||||
/// "issue" or "mr".
|
||||
pub entity_type: String,
|
||||
pub iid: u64,
|
||||
pub title: String,
|
||||
pub state: String,
|
||||
pub minutes_ago: u64,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LastSyncInfo
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Summary of the most recent sync run.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LastSyncInfo {
|
||||
pub status: String,
|
||||
/// Milliseconds epoch UTC.
|
||||
pub finished_at: Option<i64>,
|
||||
pub command: String,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DashboardData
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Data returned by the `fetch_dashboard` action.
|
||||
///
|
||||
/// Pure data transfer — no rendering or display logic.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DashboardData {
|
||||
pub counts: EntityCounts,
|
||||
pub projects: Vec<ProjectSyncInfo>,
|
||||
pub recent: Vec<RecentActivityItem>,
|
||||
pub last_sync: Option<LastSyncInfo>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DashboardState
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// State for the dashboard summary screen.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct DashboardState {
|
||||
pub issue_count: u64,
|
||||
pub mr_count: u64,
|
||||
pub counts: EntityCounts,
|
||||
pub projects: Vec<ProjectSyncInfo>,
|
||||
pub recent: Vec<RecentActivityItem>,
|
||||
pub last_sync: Option<LastSyncInfo>,
|
||||
/// Scroll offset for the recent activity list.
|
||||
pub scroll_offset: usize,
|
||||
}
|
||||
|
||||
impl DashboardState {
|
||||
/// Apply fresh data from a `fetch_dashboard` result.
|
||||
///
|
||||
/// Preserves scroll offset (clamped to new data bounds).
|
||||
pub fn update(&mut self, data: DashboardData) {
|
||||
self.counts = data.counts;
|
||||
self.projects = data.projects;
|
||||
self.last_sync = data.last_sync;
|
||||
self.recent = data.recent;
|
||||
// Clamp scroll offset if the list shrunk.
|
||||
if !self.recent.is_empty() {
|
||||
self.scroll_offset = self.scroll_offset.min(self.recent.len() - 1);
|
||||
} else {
|
||||
self.scroll_offset = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Scroll the recent activity list down by one.
|
||||
pub fn scroll_down(&mut self) {
|
||||
if !self.recent.is_empty() {
|
||||
self.scroll_offset = (self.scroll_offset + 1).min(self.recent.len() - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Scroll the recent activity list up by one.
|
||||
pub fn scroll_up(&mut self) {
|
||||
self.scroll_offset = self.scroll_offset.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_dashboard_state_default() {
|
||||
let state = DashboardState::default();
|
||||
assert_eq!(state.counts.issues_total, 0);
|
||||
assert_eq!(state.scroll_offset, 0);
|
||||
assert!(state.recent.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dashboard_state_update_applies_data() {
|
||||
let mut state = DashboardState::default();
|
||||
let data = DashboardData {
|
||||
counts: EntityCounts {
|
||||
issues_open: 3,
|
||||
issues_total: 5,
|
||||
..Default::default()
|
||||
},
|
||||
projects: vec![ProjectSyncInfo {
|
||||
path: "group/project".into(),
|
||||
minutes_since_sync: 42,
|
||||
}],
|
||||
recent: vec![RecentActivityItem {
|
||||
entity_type: "issue".into(),
|
||||
iid: 1,
|
||||
title: "Fix bug".into(),
|
||||
state: "opened".into(),
|
||||
minutes_ago: 10,
|
||||
}],
|
||||
last_sync: None,
|
||||
};
|
||||
|
||||
state.update(data);
|
||||
assert_eq!(state.counts.issues_open, 3);
|
||||
assert_eq!(state.counts.issues_total, 5);
|
||||
assert_eq!(state.projects.len(), 1);
|
||||
assert_eq!(state.recent.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dashboard_state_update_clamps_scroll() {
|
||||
let mut state = DashboardState {
|
||||
scroll_offset: 10,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let data = DashboardData {
|
||||
recent: vec![RecentActivityItem {
|
||||
entity_type: "issue".into(),
|
||||
iid: 1,
|
||||
title: "Only item".into(),
|
||||
state: "opened".into(),
|
||||
minutes_ago: 5,
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
state.update(data);
|
||||
assert_eq!(state.scroll_offset, 0); // Clamped to len-1 = 0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dashboard_state_update_empty_resets_scroll() {
|
||||
let mut state = DashboardState {
|
||||
scroll_offset: 5,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
state.update(DashboardData::default());
|
||||
assert_eq!(state.scroll_offset, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scroll_down_and_up() {
|
||||
let mut state = DashboardState::default();
|
||||
state.recent = (0..5)
|
||||
.map(|i| RecentActivityItem {
|
||||
entity_type: "issue".into(),
|
||||
iid: i,
|
||||
title: format!("Item {i}"),
|
||||
state: "opened".into(),
|
||||
minutes_ago: i,
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert_eq!(state.scroll_offset, 0);
|
||||
state.scroll_down();
|
||||
assert_eq!(state.scroll_offset, 1);
|
||||
state.scroll_down();
|
||||
assert_eq!(state.scroll_offset, 2);
|
||||
state.scroll_up();
|
||||
assert_eq!(state.scroll_offset, 1);
|
||||
state.scroll_up();
|
||||
assert_eq!(state.scroll_offset, 0);
|
||||
state.scroll_up(); // Can't go below 0
|
||||
assert_eq!(state.scroll_offset, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scroll_down_stops_at_end() {
|
||||
let mut state = DashboardState::default();
|
||||
state.recent = vec![RecentActivityItem {
|
||||
entity_type: "mr".into(),
|
||||
iid: 1,
|
||||
title: "Only".into(),
|
||||
state: "merged".into(),
|
||||
minutes_ago: 0,
|
||||
}];
|
||||
|
||||
state.scroll_down();
|
||||
assert_eq!(state.scroll_offset, 0); // Can't scroll past single item
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scroll_on_empty_is_noop() {
|
||||
let mut state = DashboardState::default();
|
||||
state.scroll_down();
|
||||
assert_eq!(state.scroll_offset, 0);
|
||||
state.scroll_up();
|
||||
assert_eq!(state.scroll_offset, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,376 @@
|
||||
#![allow(dead_code)]
|
||||
#![allow(dead_code)] // Phase 2: consumed by LoreApp and view/issue_list
|
||||
|
||||
//! Issue list screen state.
|
||||
//!
|
||||
//! Uses keyset pagination with a snapshot fence for stable ordering
|
||||
//! under concurrent sync writes. Filter changes reset the pagination
|
||||
//! cursor and snapshot fence.
|
||||
|
||||
use crate::message::IssueRow;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cursor (keyset pagination boundary)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Keyset pagination cursor — (updated_at, iid) boundary.
|
||||
///
|
||||
/// The next page query uses `WHERE (updated_at, iid) < (cursor.updated_at, cursor.iid)`
|
||||
/// to avoid OFFSET instability.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct IssueCursor {
|
||||
pub updated_at: i64,
|
||||
pub iid: i64,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Structured filter for issue list queries.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct IssueFilter {
|
||||
pub state: Option<String>,
|
||||
pub author: Option<String>,
|
||||
pub assignee: Option<String>,
|
||||
pub label: Option<String>,
|
||||
pub milestone: Option<String>,
|
||||
pub status: Option<String>,
|
||||
pub free_text: Option<String>,
|
||||
pub project_id: Option<i64>,
|
||||
}
|
||||
|
||||
impl IssueFilter {
|
||||
/// Compute a hash for change detection.
|
||||
pub fn hash_value(&self) -> u64 {
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
self.state.hash(&mut hasher);
|
||||
self.author.hash(&mut hasher);
|
||||
self.assignee.hash(&mut hasher);
|
||||
self.label.hash(&mut hasher);
|
||||
self.milestone.hash(&mut hasher);
|
||||
self.status.hash(&mut hasher);
|
||||
self.free_text.hash(&mut hasher);
|
||||
self.project_id.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
/// Whether any filter is active.
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.state.is_some()
|
||||
|| self.author.is_some()
|
||||
|| self.assignee.is_some()
|
||||
|| self.label.is_some()
|
||||
|| self.milestone.is_some()
|
||||
|| self.status.is_some()
|
||||
|| self.free_text.is_some()
|
||||
|| self.project_id.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Row
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A single row in the issue list.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IssueListRow {
|
||||
pub project_path: String,
|
||||
pub iid: i64,
|
||||
pub title: String,
|
||||
pub state: String,
|
||||
pub author: String,
|
||||
pub labels: Vec<String>,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page result
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Result from a paginated issue list query.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IssueListPage {
|
||||
pub rows: Vec<IssueListRow>,
|
||||
pub next_cursor: Option<IssueCursor>,
|
||||
pub total_count: u64,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sort
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Fields available for sorting.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum SortField {
|
||||
#[default]
|
||||
UpdatedAt,
|
||||
Iid,
|
||||
Title,
|
||||
State,
|
||||
Author,
|
||||
}
|
||||
|
||||
/// Sort direction.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum SortOrder {
|
||||
#[default]
|
||||
Desc,
|
||||
Asc,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IssueListState
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// State for the issue list screen.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct IssueListState {
|
||||
pub rows: Vec<IssueRow>,
|
||||
pub filter: String,
|
||||
pub filter_focused: bool,
|
||||
/// Current page of issue rows.
|
||||
pub rows: Vec<IssueListRow>,
|
||||
/// Total count of matching issues.
|
||||
pub total_count: u64,
|
||||
/// Selected row index (within current window).
|
||||
pub selected_index: usize,
|
||||
/// Scroll offset for the entity table.
|
||||
pub scroll_offset: usize,
|
||||
/// Cursor for the next page.
|
||||
pub next_cursor: Option<IssueCursor>,
|
||||
/// Whether a prefetch is in flight.
|
||||
pub prefetch_in_flight: bool,
|
||||
/// Current filter.
|
||||
pub filter: IssueFilter,
|
||||
/// Raw filter input text.
|
||||
pub filter_input: String,
|
||||
/// Whether the filter bar has focus.
|
||||
pub filter_focused: bool,
|
||||
/// Sort field.
|
||||
pub sort_field: SortField,
|
||||
/// Sort direction.
|
||||
pub sort_order: SortOrder,
|
||||
/// Snapshot fence: max updated_at from initial load.
|
||||
pub snapshot_fence: Option<i64>,
|
||||
/// Hash of the current filter for change detection.
|
||||
pub filter_hash: u64,
|
||||
/// Whether Quick Peek is visible.
|
||||
pub peek_visible: bool,
|
||||
}
|
||||
|
||||
impl IssueListState {
|
||||
/// Reset pagination state (called when filter changes or on refresh).
|
||||
pub fn reset_pagination(&mut self) {
|
||||
self.rows.clear();
|
||||
self.next_cursor = None;
|
||||
self.selected_index = 0;
|
||||
self.scroll_offset = 0;
|
||||
self.snapshot_fence = None;
|
||||
self.total_count = 0;
|
||||
self.prefetch_in_flight = false;
|
||||
}
|
||||
|
||||
/// Apply a new page of results.
|
||||
pub fn apply_page(&mut self, page: IssueListPage) {
|
||||
// Set snapshot fence on first page load.
|
||||
if self.snapshot_fence.is_none() {
|
||||
self.snapshot_fence = page.rows.first().map(|r| r.updated_at);
|
||||
}
|
||||
self.rows.extend(page.rows);
|
||||
self.next_cursor = page.next_cursor;
|
||||
self.total_count = page.total_count;
|
||||
self.prefetch_in_flight = false;
|
||||
}
|
||||
|
||||
/// Check if filter changed and reset if needed.
|
||||
pub fn check_filter_change(&mut self) -> bool {
|
||||
let new_hash = self.filter.hash_value();
|
||||
if new_hash != self.filter_hash {
|
||||
self.filter_hash = new_hash;
|
||||
self.reset_pagination();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the user has scrolled near the end of current data (80% threshold).
|
||||
pub fn should_prefetch(&self) -> bool {
|
||||
if self.prefetch_in_flight || self.next_cursor.is_none() {
|
||||
return false;
|
||||
}
|
||||
if self.rows.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let threshold = (self.rows.len() * 4) / 5; // 80%
|
||||
self.selected_index >= threshold
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_page(count: usize, has_next: bool) -> IssueListPage {
|
||||
let rows: Vec<IssueListRow> = (0..count)
|
||||
.map(|i| IssueListRow {
|
||||
project_path: "group/project".into(),
|
||||
iid: (count - i) as i64,
|
||||
title: format!("Issue {}", count - i),
|
||||
state: "opened".into(),
|
||||
author: "taylor".into(),
|
||||
labels: vec![],
|
||||
updated_at: 1_700_000_000_000 - (i as i64 * 60_000),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let next_cursor = if has_next {
|
||||
rows.last().map(|r| IssueCursor {
|
||||
updated_at: r.updated_at,
|
||||
iid: r.iid,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
IssueListPage {
|
||||
rows,
|
||||
next_cursor,
|
||||
total_count: if has_next {
|
||||
(count * 2) as u64
|
||||
} else {
|
||||
count as u64
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_page_sets_snapshot_fence() {
|
||||
let mut state = IssueListState::default();
|
||||
let page = sample_page(5, false);
|
||||
state.apply_page(page);
|
||||
|
||||
assert_eq!(state.rows.len(), 5);
|
||||
assert!(state.snapshot_fence.is_some());
|
||||
assert_eq!(state.snapshot_fence.unwrap(), 1_700_000_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_page_appends() {
|
||||
let mut state = IssueListState::default();
|
||||
state.apply_page(sample_page(5, true));
|
||||
assert_eq!(state.rows.len(), 5);
|
||||
|
||||
state.apply_page(sample_page(3, false));
|
||||
assert_eq!(state.rows.len(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reset_pagination_clears_state() {
|
||||
let mut state = IssueListState::default();
|
||||
state.apply_page(sample_page(5, true));
|
||||
state.selected_index = 3;
|
||||
|
||||
state.reset_pagination();
|
||||
|
||||
assert!(state.rows.is_empty());
|
||||
assert_eq!(state.selected_index, 0);
|
||||
assert!(state.next_cursor.is_none());
|
||||
assert!(state.snapshot_fence.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_filter_change_detects_change() {
|
||||
let mut state = IssueListState::default();
|
||||
state.filter_hash = state.filter.hash_value();
|
||||
|
||||
state.filter.state = Some("opened".into());
|
||||
assert!(state.check_filter_change());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_filter_change_no_change() {
|
||||
let mut state = IssueListState::default();
|
||||
state.filter_hash = state.filter.hash_value();
|
||||
assert!(!state.check_filter_change());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_prefetch() {
|
||||
let mut state = IssueListState::default();
|
||||
state.apply_page(sample_page(10, true));
|
||||
|
||||
state.selected_index = 4; // 40% — no prefetch
|
||||
assert!(!state.should_prefetch());
|
||||
|
||||
state.selected_index = 8; // 80% — prefetch
|
||||
assert!(state.should_prefetch());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_prefetch_no_next_page() {
|
||||
let mut state = IssueListState::default();
|
||||
state.apply_page(sample_page(10, false));
|
||||
state.selected_index = 9;
|
||||
assert!(!state.should_prefetch());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_prefetch_already_in_flight() {
|
||||
let mut state = IssueListState::default();
|
||||
state.apply_page(sample_page(10, true));
|
||||
state.selected_index = 9;
|
||||
state.prefetch_in_flight = true;
|
||||
assert!(!state.should_prefetch());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_filter_is_active() {
|
||||
let empty = IssueFilter::default();
|
||||
assert!(!empty.is_active());
|
||||
|
||||
let active = IssueFilter {
|
||||
state: Some("opened".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(active.is_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_filter_hash_deterministic() {
|
||||
let f1 = IssueFilter {
|
||||
state: Some("opened".into()),
|
||||
author: Some("taylor".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let f2 = f1.clone();
|
||||
assert_eq!(f1.hash_value(), f2.hash_value());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_filter_hash_differs() {
|
||||
let f1 = IssueFilter {
|
||||
state: Some("opened".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let f2 = IssueFilter {
|
||||
state: Some("closed".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_ne!(f1.hash_value(), f2.hash_value());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_snapshot_fence_not_overwritten_on_second_page() {
|
||||
let mut state = IssueListState::default();
|
||||
state.apply_page(sample_page(5, true));
|
||||
let fence = state.snapshot_fence;
|
||||
|
||||
state.apply_page(sample_page(3, false));
|
||||
assert_eq!(
|
||||
state.snapshot_fence, fence,
|
||||
"Fence should not change on second page"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ pub mod sync;
|
||||
pub mod timeline;
|
||||
pub mod who;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::message::Screen;
|
||||
|
||||
@@ -80,6 +80,8 @@ impl LoadState {
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ScreenLoadStateMap {
|
||||
map: HashMap<Screen, LoadState>,
|
||||
/// Screens that have had a load state set at least once.
|
||||
visited: HashSet<Screen>,
|
||||
}
|
||||
|
||||
impl ScreenLoadStateMap {
|
||||
@@ -94,6 +96,7 @@ impl ScreenLoadStateMap {
|
||||
///
|
||||
/// Setting to `Idle` removes the entry to prevent map growth.
|
||||
pub fn set(&mut self, screen: Screen, state: LoadState) {
|
||||
self.visited.insert(screen.clone());
|
||||
if state == LoadState::Idle {
|
||||
self.map.remove(&screen);
|
||||
} else {
|
||||
@@ -101,6 +104,12 @@ impl ScreenLoadStateMap {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this screen has ever had a load initiated.
|
||||
#[must_use]
|
||||
pub fn was_visited(&self, screen: &Screen) -> bool {
|
||||
self.visited.contains(screen)
|
||||
}
|
||||
|
||||
/// Whether any screen is currently loading.
|
||||
#[must_use]
|
||||
pub fn any_loading(&self) -> bool {
|
||||
|
||||
@@ -1,14 +1,422 @@
|
||||
#![allow(dead_code)]
|
||||
#![allow(dead_code)] // Phase 2: consumed by LoreApp and view/mr_list
|
||||
|
||||
//! Merge request list screen state.
|
||||
//!
|
||||
//! Mirrors the issue list pattern with MR-specific filter fields
|
||||
//! (draft, reviewer, target/source branch). Uses the same keyset
|
||||
//! pagination with snapshot fence for stable ordering.
|
||||
|
||||
use crate::message::MrRow;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cursor (keyset pagination boundary)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Keyset pagination cursor — (updated_at, iid) boundary.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MrCursor {
|
||||
pub updated_at: i64,
|
||||
pub iid: i64,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Structured filter for MR list queries.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct MrFilter {
|
||||
pub state: Option<String>,
|
||||
pub author: Option<String>,
|
||||
pub reviewer: Option<String>,
|
||||
pub target_branch: Option<String>,
|
||||
pub source_branch: Option<String>,
|
||||
pub label: Option<String>,
|
||||
pub draft: Option<bool>,
|
||||
pub free_text: Option<String>,
|
||||
pub project_id: Option<i64>,
|
||||
}
|
||||
|
||||
impl MrFilter {
|
||||
/// Compute a hash for change detection.
|
||||
pub fn hash_value(&self) -> u64 {
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
self.state.hash(&mut hasher);
|
||||
self.author.hash(&mut hasher);
|
||||
self.reviewer.hash(&mut hasher);
|
||||
self.target_branch.hash(&mut hasher);
|
||||
self.source_branch.hash(&mut hasher);
|
||||
self.label.hash(&mut hasher);
|
||||
self.draft.hash(&mut hasher);
|
||||
self.free_text.hash(&mut hasher);
|
||||
self.project_id.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
/// Whether any filter is active.
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.state.is_some()
|
||||
|| self.author.is_some()
|
||||
|| self.reviewer.is_some()
|
||||
|| self.target_branch.is_some()
|
||||
|| self.source_branch.is_some()
|
||||
|| self.label.is_some()
|
||||
|| self.draft.is_some()
|
||||
|| self.free_text.is_some()
|
||||
|| self.project_id.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Row
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A single row in the MR list.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MrListRow {
|
||||
pub project_path: String,
|
||||
pub iid: i64,
|
||||
pub title: String,
|
||||
pub state: String,
|
||||
pub author: String,
|
||||
pub target_branch: String,
|
||||
pub labels: Vec<String>,
|
||||
pub updated_at: i64,
|
||||
pub draft: bool,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page result
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Result from a paginated MR list query.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MrListPage {
|
||||
pub rows: Vec<MrListRow>,
|
||||
pub next_cursor: Option<MrCursor>,
|
||||
pub total_count: u64,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sort
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Fields available for sorting.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum MrSortField {
|
||||
#[default]
|
||||
UpdatedAt,
|
||||
Iid,
|
||||
Title,
|
||||
State,
|
||||
Author,
|
||||
TargetBranch,
|
||||
}
|
||||
|
||||
/// Sort direction.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum MrSortOrder {
|
||||
#[default]
|
||||
Desc,
|
||||
Asc,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MrListState
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// State for the MR list screen.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct MrListState {
|
||||
pub rows: Vec<MrRow>,
|
||||
pub filter: String,
|
||||
pub filter_focused: bool,
|
||||
/// Current page of MR rows.
|
||||
pub rows: Vec<MrListRow>,
|
||||
/// Total count of matching MRs.
|
||||
pub total_count: u64,
|
||||
/// Selected row index (within current window).
|
||||
pub selected_index: usize,
|
||||
/// Scroll offset for the entity table.
|
||||
pub scroll_offset: usize,
|
||||
/// Cursor for the next page.
|
||||
pub next_cursor: Option<MrCursor>,
|
||||
/// Whether a prefetch is in flight.
|
||||
pub prefetch_in_flight: bool,
|
||||
/// Current filter.
|
||||
pub filter: MrFilter,
|
||||
/// Raw filter input text.
|
||||
pub filter_input: String,
|
||||
/// Whether the filter bar has focus.
|
||||
pub filter_focused: bool,
|
||||
/// Sort field.
|
||||
pub sort_field: MrSortField,
|
||||
/// Sort direction.
|
||||
pub sort_order: MrSortOrder,
|
||||
/// Snapshot fence: max updated_at from initial load.
|
||||
pub snapshot_fence: Option<i64>,
|
||||
/// Hash of the current filter for change detection.
|
||||
pub filter_hash: u64,
|
||||
/// Whether Quick Peek is visible.
|
||||
pub peek_visible: bool,
|
||||
}
|
||||
|
||||
impl MrListState {
|
||||
/// Reset pagination state (called when filter changes or on refresh).
|
||||
pub fn reset_pagination(&mut self) {
|
||||
self.rows.clear();
|
||||
self.next_cursor = None;
|
||||
self.selected_index = 0;
|
||||
self.scroll_offset = 0;
|
||||
self.snapshot_fence = None;
|
||||
self.total_count = 0;
|
||||
self.prefetch_in_flight = false;
|
||||
}
|
||||
|
||||
/// Apply a new page of results.
|
||||
pub fn apply_page(&mut self, page: MrListPage) {
|
||||
// Set snapshot fence on first page load.
|
||||
if self.snapshot_fence.is_none() {
|
||||
self.snapshot_fence = page.rows.first().map(|r| r.updated_at);
|
||||
}
|
||||
self.rows.extend(page.rows);
|
||||
self.next_cursor = page.next_cursor;
|
||||
self.total_count = page.total_count;
|
||||
self.prefetch_in_flight = false;
|
||||
}
|
||||
|
||||
/// Check if filter changed and reset if needed.
|
||||
pub fn check_filter_change(&mut self) -> bool {
|
||||
let new_hash = self.filter.hash_value();
|
||||
if new_hash != self.filter_hash {
|
||||
self.filter_hash = new_hash;
|
||||
self.reset_pagination();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the user has scrolled near the end of current data (80% threshold).
|
||||
pub fn should_prefetch(&self) -> bool {
|
||||
if self.prefetch_in_flight || self.next_cursor.is_none() {
|
||||
return false;
|
||||
}
|
||||
if self.rows.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let threshold = (self.rows.len() * 4) / 5; // 80%
|
||||
self.selected_index >= threshold
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_page(count: usize, has_next: bool) -> MrListPage {
|
||||
let rows: Vec<MrListRow> = (0..count)
|
||||
.map(|i| MrListRow {
|
||||
project_path: "group/project".into(),
|
||||
iid: (count - i) as i64,
|
||||
title: format!("MR {}", count - i),
|
||||
state: "opened".into(),
|
||||
author: "taylor".into(),
|
||||
target_branch: "main".into(),
|
||||
labels: vec![],
|
||||
updated_at: 1_700_000_000_000 - (i as i64 * 60_000),
|
||||
draft: i % 3 == 0,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let next_cursor = if has_next {
|
||||
rows.last().map(|r| MrCursor {
|
||||
updated_at: r.updated_at,
|
||||
iid: r.iid,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
MrListPage {
|
||||
rows,
|
||||
next_cursor,
|
||||
total_count: if has_next {
|
||||
(count * 2) as u64
|
||||
} else {
|
||||
count as u64
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_page_sets_snapshot_fence() {
|
||||
let mut state = MrListState::default();
|
||||
let page = sample_page(5, false);
|
||||
state.apply_page(page);
|
||||
|
||||
assert_eq!(state.rows.len(), 5);
|
||||
assert!(state.snapshot_fence.is_some());
|
||||
assert_eq!(state.snapshot_fence.unwrap(), 1_700_000_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_page_appends() {
|
||||
let mut state = MrListState::default();
|
||||
state.apply_page(sample_page(5, true));
|
||||
assert_eq!(state.rows.len(), 5);
|
||||
|
||||
state.apply_page(sample_page(3, false));
|
||||
assert_eq!(state.rows.len(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reset_pagination_clears_state() {
|
||||
let mut state = MrListState::default();
|
||||
state.apply_page(sample_page(5, true));
|
||||
state.selected_index = 3;
|
||||
|
||||
state.reset_pagination();
|
||||
|
||||
assert!(state.rows.is_empty());
|
||||
assert_eq!(state.selected_index, 0);
|
||||
assert!(state.next_cursor.is_none());
|
||||
assert!(state.snapshot_fence.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_filter_change_detects_change() {
|
||||
let mut state = MrListState::default();
|
||||
state.filter_hash = state.filter.hash_value();
|
||||
|
||||
state.filter.state = Some("opened".into());
|
||||
assert!(state.check_filter_change());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_filter_change_no_change() {
|
||||
let mut state = MrListState::default();
|
||||
state.filter_hash = state.filter.hash_value();
|
||||
assert!(!state.check_filter_change());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_prefetch() {
|
||||
let mut state = MrListState::default();
|
||||
state.apply_page(sample_page(10, true));
|
||||
|
||||
state.selected_index = 4; // 40% -- no prefetch
|
||||
assert!(!state.should_prefetch());
|
||||
|
||||
state.selected_index = 8; // 80% -- prefetch
|
||||
assert!(state.should_prefetch());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_prefetch_no_next_page() {
|
||||
let mut state = MrListState::default();
|
||||
state.apply_page(sample_page(10, false));
|
||||
state.selected_index = 9;
|
||||
assert!(!state.should_prefetch());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_prefetch_already_in_flight() {
|
||||
let mut state = MrListState::default();
|
||||
state.apply_page(sample_page(10, true));
|
||||
state.selected_index = 9;
|
||||
state.prefetch_in_flight = true;
|
||||
assert!(!state.should_prefetch());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mr_filter_is_active() {
|
||||
let empty = MrFilter::default();
|
||||
assert!(!empty.is_active());
|
||||
|
||||
let active = MrFilter {
|
||||
state: Some("opened".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(active.is_active());
|
||||
|
||||
let draft_active = MrFilter {
|
||||
draft: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(draft_active.is_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mr_filter_hash_deterministic() {
|
||||
let f1 = MrFilter {
|
||||
state: Some("opened".into()),
|
||||
author: Some("taylor".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let f2 = f1.clone();
|
||||
assert_eq!(f1.hash_value(), f2.hash_value());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mr_filter_hash_differs() {
|
||||
let f1 = MrFilter {
|
||||
state: Some("opened".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let f2 = MrFilter {
|
||||
state: Some("merged".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_ne!(f1.hash_value(), f2.hash_value());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_snapshot_fence_not_overwritten_on_second_page() {
|
||||
let mut state = MrListState::default();
|
||||
state.apply_page(sample_page(5, true));
|
||||
let fence = state.snapshot_fence;
|
||||
|
||||
state.apply_page(sample_page(3, false));
|
||||
assert_eq!(
|
||||
state.snapshot_fence, fence,
|
||||
"Fence should not change on second page"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mr_filter_reviewer_field() {
|
||||
let f = MrFilter {
|
||||
reviewer: Some("alice".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(f.is_active());
|
||||
assert_ne!(f.hash_value(), MrFilter::default().hash_value());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mr_filter_target_branch_field() {
|
||||
let f = MrFilter {
|
||||
target_branch: Some("main".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(f.is_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mr_list_row_draft_field() {
|
||||
let row = MrListRow {
|
||||
project_path: "g/p".into(),
|
||||
iid: 1,
|
||||
title: "Draft MR".into(),
|
||||
state: "opened".into(),
|
||||
author: "taylor".into(),
|
||||
target_branch: "main".into(),
|
||||
labels: vec![],
|
||||
updated_at: 0,
|
||||
draft: true,
|
||||
};
|
||||
assert!(row.draft);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user