feat(tui): wire entity cache for near-instant detail view reopens (bd-3rjw)

- Add get_mut() and clear() methods to EntityCache<V>
- Add CachedIssuePayload / CachedMrPayload types to state
- Wire cache check in navigate_to for instant cache hits
- Populate cache on IssueDetailLoaded / MrDetailLoaded
- Update cache on DiscussionsLoaded
- Add 6 new entity_cache tests (get_mut, clear)
This commit is contained in:
teernisse
2026-02-19 00:25:04 -05:00
parent 026b3f0754
commit 04ea1f7673
13 changed files with 575 additions and 34 deletions

View File

@@ -371,7 +371,11 @@ fn render_mr_list(
// Inline discussion snippets (rendered beneath MRs when toggled on).
if state.show_discussions && !result.discussions.is_empty() {
let visible_mrs = result.merge_requests.len().saturating_sub(offset).min(height);
let visible_mrs = result
.merge_requests
.len()
.saturating_sub(offset)
.min(height);
let disc_start_y = start_y + visible_mrs as u16;
let remaining = height.saturating_sub(visible_mrs);
render_discussions(frame, result, x, disc_start_y, max_x, remaining, bp);

View File

@@ -113,9 +113,7 @@ pub fn render_issue_detail(
y = render_metadata_row(frame, meta, bp, area.x, y, max_x);
// --- Optional milestone / due date row (skip on Xs — too narrow) ---
if !matches!(bp, Breakpoint::Xs)
&& (meta.milestone.is_some() || meta.due_date.is_some())
{
if !matches!(bp, Breakpoint::Xs) && (meta.milestone.is_some() || meta.due_date.is_some()) {
y = render_milestone_row(frame, meta, area.x, y, max_x);
}
@@ -136,7 +134,8 @@ pub fn render_issue_detail(
let xref_count = state.cross_refs.len();
let wide = detail_side_panel(bp);
let (desc_h, disc_h, xref_h) = allocate_sections(remaining, desc_lines, disc_count, xref_count, wide);
let (desc_h, disc_h, xref_h) =
allocate_sections(remaining, desc_lines, disc_count, xref_count, wide);
// --- Description section ---
if desc_h > 0 {
@@ -629,7 +628,10 @@ mod tests {
fn test_allocate_sections_wide_gives_more_description() {
let (d_narrow, _, _) = allocate_sections(20, 10, 3, 2, false);
let (d_wide, _, _) = allocate_sections(20, 10, 3, 2, true);
assert!(d_wide >= d_narrow, "wide should give desc at least as much space");
assert!(
d_wide >= d_narrow,
"wide should give desc at least as much space"
);
}
#[test]

View File

@@ -102,7 +102,15 @@ pub fn render_search(frame: &mut Frame<'_>, state: &SearchState, area: Rect) {
if state.results.is_empty() {
render_empty_state(frame, state, area.x + 1, y, max_x);
} else {
render_result_list(frame, state, area.x, y, area.width, list_height, show_project);
render_result_list(
frame,
state,
area.x,
y,
area.width,
list_height,
show_project,
);
}
// -- Bottom hint bar -----------------------------------------------------

View File

@@ -115,10 +115,7 @@ fn render_running(frame: &mut Frame<'_>, state: &SyncState, area: Rect) {
let bar_start_y = area.y + 4;
let label_width = 14u16; // "Discussions " is the longest
let bar_x = area.x + 2 + label_width;
let bar_width = area
.width
.saturating_sub(4 + label_width + 12)
.min(max_bar); // Cap bar width for very wide terminals
let bar_width = area.width.saturating_sub(4 + label_width + 12).min(max_bar); // Cap bar width for very wide terminals
for (i, lane) in SyncLane::ALL.iter().enumerate() {
let y = bar_start_y + i as u16;

View File

@@ -124,7 +124,16 @@ pub fn render_timeline(
} else {
let bp = classify_width(area.width);
let time_col_width = timeline_time_width(bp);
render_event_list(frame, state, area.x, y, area.width, list_height, clock, time_col_width);
render_event_list(
frame,
state,
area.x,
y,
area.width,
list_height,
clock,
time_col_width,
);
}
// -- Hint bar --

View File

@@ -226,13 +226,7 @@ fn render_input_bar(
.get(cursor_pos..)
.and_then(|s| s.chars().next())
.unwrap_or(' ');
frame.print_text_clipped(
cursor_x,
y,
&cursor_char.to_string(),
cursor_cell,
max_x,
);
frame.print_text_clipped(cursor_x, y, &cursor_char.to_string(), cursor_cell, max_x);
}
}
}