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:
teernisse
2026-02-18 13:06:06 -05:00
parent c5b7f4c864
commit 90c8b43267
33 changed files with 7850 additions and 483 deletions

View File

@@ -60,9 +60,10 @@ impl NavigationStack {
self.forward_stack.clear();
// Record significant hops in jump list (vim behavior):
// truncate any forward entries beyond jump_index, then append.
// Keep entries up to and including the current position, discard
// any forward entries beyond it, then append the new destination.
if self.current.is_detail_or_entity() {
self.jump_list.truncate(self.jump_index);
self.jump_list.truncate(self.jump_index.saturating_add(1));
self.jump_list.push(self.current.clone());
self.jump_index = self.jump_list.len();
}
@@ -90,23 +91,37 @@ impl NavigationStack {
/// Jump backward through the jump list (vim Ctrl+O).
///
/// Only visits detail/entity screens.
/// Only visits detail/entity screens. Skips entries matching the
/// current screen so the first press always produces a visible change.
pub fn jump_back(&mut self) -> Option<&Screen> {
if self.jump_index == 0 {
return None;
while self.jump_index > 0 {
self.jump_index -= 1;
if let Some(target) = self.jump_list.get(self.jump_index).cloned()
&& target != self.current
{
self.current = target;
return Some(&self.current);
}
}
self.jump_index -= 1;
self.jump_list.get(self.jump_index)
None
}
/// Jump forward through the jump list (vim Ctrl+I).
///
/// Skips entries matching the current screen.
pub fn jump_forward(&mut self) -> Option<&Screen> {
if self.jump_index >= self.jump_list.len() {
return None;
while self.jump_index < self.jump_list.len() {
if let Some(target) = self.jump_list.get(self.jump_index).cloned() {
self.jump_index += 1;
if target != self.current {
self.current = target;
return Some(&self.current);
}
} else {
break;
}
}
let screen = self.jump_list.get(self.jump_index)?;
self.jump_index += 1;
Some(screen)
None
}
/// Reset to a single screen, clearing all history.
@@ -246,24 +261,21 @@ mod tests {
nav.push(Screen::MrList);
nav.push(mr.clone());
// jump_index is at 2 (past the end of 2 items)
let prev = nav.jump_back();
assert_eq!(prev, Some(&mr));
// Current is MrDetail. jump_list = [IssueDetail, MrDetail], index = 2.
// First jump_back skips MrDetail (== current) and lands on IssueDetail.
let prev = nav.jump_back();
assert_eq!(prev, Some(&issue));
assert!(nav.is_at(&issue));
// at beginning
// Already at beginning of jump list.
assert!(nav.jump_back().is_none());
// forward
let next = nav.jump_forward();
assert_eq!(next, Some(&issue));
// jump_forward skips IssueDetail (== current) and lands on MrDetail.
let next = nav.jump_forward();
assert_eq!(next, Some(&mr));
assert!(nav.is_at(&mr));
// at end
// At end of jump list.
assert!(nav.jump_forward().is_none());
}
@@ -274,10 +286,9 @@ mod tests {
nav.push(Screen::IssueDetail(EntityKey::issue(1, 2)));
nav.push(Screen::IssueDetail(EntityKey::issue(1, 3)));
// jump back twice
// jump back twice — lands on issue(1,1), jump_index = 0
nav.jump_back();
nav.jump_back();
// jump_index = 1, pointing at issue 2
// new detail push truncates forward entries
nav.push(Screen::MrDetail(EntityKey::mr(1, 99)));