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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
bd-a6yb bd-3rjw

View File

@@ -40,13 +40,15 @@ pub fn fetch_trace(
pub fn fetch_known_paths(conn: &Connection, project_id: Option<i64>) -> Result<Vec<String>> { pub fn fetch_known_paths(conn: &Connection, project_id: Option<i64>) -> Result<Vec<String>> {
let paths = if let Some(pid) = project_id { let paths = if let Some(pid) = project_id {
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT DISTINCT new_path FROM mr_file_changes WHERE project_id = ?1 ORDER BY new_path", "SELECT DISTINCT new_path FROM mr_file_changes \
WHERE project_id = ?1 ORDER BY new_path LIMIT 5000",
)?; )?;
let rows = stmt.query_map([pid], |row| row.get::<_, String>(0))?; let rows = stmt.query_map([pid], |row| row.get::<_, String>(0))?;
rows.collect::<std::result::Result<Vec<_>, _>>()? rows.collect::<std::result::Result<Vec<_>, _>>()?
} else { } else {
let mut stmt = let mut stmt = conn.prepare(
conn.prepare("SELECT DISTINCT new_path FROM mr_file_changes ORDER BY new_path")?; "SELECT DISTINCT new_path FROM mr_file_changes ORDER BY new_path LIMIT 5000",
)?;
let rows = stmt.query_map([], |row| row.get::<_, String>(0))?; let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
rows.collect::<std::result::Result<Vec<_>, _>>()? rows.collect::<std::result::Result<Vec<_>, _>>()?
}; };

View File

@@ -4,8 +4,8 @@ use chrono::TimeDelta;
use ftui::{Cmd, Event, Frame, KeyCode, KeyEvent, Model, Modifiers}; use ftui::{Cmd, Event, Frame, KeyCode, KeyEvent, Model, Modifiers};
use crate::crash_context::CrashEvent; use crate::crash_context::CrashEvent;
use crate::message::{InputMode, Msg, Screen}; use crate::message::{EntityKind, InputMode, Msg, Screen};
use crate::state::LoadState; use crate::state::{CachedIssuePayload, CachedMrPayload, LoadState};
use crate::task_supervisor::TaskKey; use crate::task_supervisor::TaskKey;
use super::LoreApp; use super::LoreApp;
@@ -263,6 +263,10 @@ impl LoreApp {
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
/// Navigate to a screen, pushing the nav stack and starting a data load. /// Navigate to a screen, pushing the nav stack and starting a data load.
///
/// For detail views (issue/MR), checks the entity cache first. On a
/// cache hit, applies cached data immediately and uses `Refreshing`
/// (background re-fetch) instead of `LoadingInitial` (full spinner).
fn navigate_to(&mut self, screen: Screen) -> Cmd<Msg> { fn navigate_to(&mut self, screen: Screen) -> Cmd<Msg> {
let screen_label = screen.label().to_string(); let screen_label = screen.label().to_string();
let current_label = self.navigation.current().label().to_string(); let current_label = self.navigation.current().label().to_string();
@@ -274,21 +278,56 @@ impl LoreApp {
self.navigation.push(screen.clone()); self.navigation.push(screen.clone());
// First visit → full-screen spinner; revisit → corner spinner over stale data. // Check entity cache for detail views — apply cached data instantly.
let load_state = if self.state.load_state.was_visited(&screen) { let cache_hit = self.try_apply_detail_cache(&screen);
// Cache hit → background refresh; first visit → full spinner; revisit → stale refresh.
let load_state = if cache_hit || self.state.load_state.was_visited(&screen) {
LoadState::Refreshing LoadState::Refreshing
} else { } else {
LoadState::LoadingInitial LoadState::LoadingInitial
}; };
self.state.set_loading(screen.clone(), load_state); self.state.set_loading(screen.clone(), load_state);
// Spawn supervised task for data loading (placeholder — actual DB // Always spawn a data load (even on cache hit, to ensure freshness).
// query dispatch comes in Phase 2 screen implementations).
let _handle = self.supervisor.submit(TaskKey::LoadScreen(screen)); let _handle = self.supervisor.submit(TaskKey::LoadScreen(screen));
Cmd::none() Cmd::none()
} }
/// Try to populate a detail view from the entity cache. Returns true on hit.
fn try_apply_detail_cache(&mut self, screen: &Screen) -> bool {
match screen {
Screen::IssueDetail(key) => {
if let Some(payload) = self.state.issue_cache.get(key).cloned() {
self.state.issue_detail.load_new(key.clone());
self.state.issue_detail.apply_metadata(payload.data);
if !payload.discussions.is_empty() {
self.state
.issue_detail
.apply_discussions(payload.discussions);
}
true
} else {
false
}
}
Screen::MrDetail(key) => {
if let Some(payload) = self.state.mr_cache.get(key).cloned() {
self.state.mr_detail.load_new(key.clone());
self.state.mr_detail.apply_metadata(payload.data);
if !payload.discussions.is_empty() {
self.state.mr_detail.apply_discussions(payload.discussions);
}
true
} else {
false
}
}
_ => false,
}
}
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Message dispatch (non-key) // Message dispatch (non-key)
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -397,6 +436,14 @@ impl LoreApp {
.supervisor .supervisor
.is_current(&TaskKey::LoadScreen(screen.clone()), generation) .is_current(&TaskKey::LoadScreen(screen.clone()), generation)
{ {
// Populate entity cache (metadata only; discussions added later).
self.state.issue_cache.put(
key,
CachedIssuePayload {
data: (*data).clone(),
discussions: Vec::new(),
},
);
self.state.issue_detail.apply_metadata(*data); self.state.issue_detail.apply_metadata(*data);
self.state.set_loading(screen.clone(), LoadState::Idle); self.state.set_loading(screen.clone(), LoadState::Idle);
self.supervisor self.supervisor
@@ -413,14 +460,24 @@ impl LoreApp {
// supervisor.complete(), so is_current() would return false. // supervisor.complete(), so is_current() would return false.
// Instead, check that the detail state still expects this key. // Instead, check that the detail state still expects this key.
match key.kind { match key.kind {
crate::message::EntityKind::Issue => { EntityKind::Issue => {
if self.state.issue_detail.current_key.as_ref() == Some(&key) { if self.state.issue_detail.current_key.as_ref() == Some(&key) {
self.state.issue_detail.apply_discussions(discussions); self.state
.issue_detail
.apply_discussions(discussions.clone());
// Update cache with discussions.
if let Some(cached) = self.state.issue_cache.get_mut(&key) {
cached.discussions = discussions;
} }
} }
crate::message::EntityKind::MergeRequest => { }
EntityKind::MergeRequest => {
if self.state.mr_detail.current_key.as_ref() == Some(&key) { if self.state.mr_detail.current_key.as_ref() == Some(&key) {
self.state.mr_detail.apply_discussions(discussions); self.state.mr_detail.apply_discussions(discussions.clone());
// Update cache with discussions.
if let Some(cached) = self.state.mr_cache.get_mut(&key) {
cached.discussions = discussions;
}
} }
} }
} }
@@ -438,6 +495,14 @@ impl LoreApp {
.supervisor .supervisor
.is_current(&TaskKey::LoadScreen(screen.clone()), generation) .is_current(&TaskKey::LoadScreen(screen.clone()), generation)
{ {
// Populate entity cache (metadata only; discussions added later).
self.state.mr_cache.put(
key,
CachedMrPayload {
data: (*data).clone(),
discussions: Vec::new(),
},
);
self.state.mr_detail.apply_metadata(*data); self.state.mr_detail.apply_metadata(*data);
self.state.set_loading(screen.clone(), LoadState::Idle); self.state.set_loading(screen.clone(), LoadState::Idle);
self.supervisor self.supervisor

View File

@@ -25,6 +25,15 @@ pub struct EntityCache<V> {
tick: u64, tick: u64,
} }
impl<V> std::fmt::Debug for EntityCache<V> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EntityCache")
.field("len", &self.entries.len())
.field("capacity", &self.capacity)
.finish()
}
}
impl<V> EntityCache<V> { impl<V> EntityCache<V> {
/// Create a new cache with the default capacity (64). /// Create a new cache with the default capacity (64).
#[must_use] #[must_use]
@@ -60,6 +69,16 @@ impl<V> EntityCache<V> {
}) })
} }
/// Look up an entry mutably, bumping its access tick to keep it alive.
pub fn get_mut(&mut self, key: &EntityKey) -> Option<&mut V> {
self.tick += 1;
let tick = self.tick;
self.entries.get_mut(key).map(|(val, t)| {
*t = tick;
val
})
}
/// Insert an entry, evicting the least-recently-accessed entry if at capacity. /// Insert an entry, evicting the least-recently-accessed entry if at capacity.
pub fn put(&mut self, key: EntityKey, value: V) { pub fn put(&mut self, key: EntityKey, value: V) {
self.tick += 1; self.tick += 1;
@@ -103,6 +122,11 @@ impl<V> EntityCache<V> {
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
self.entries.is_empty() self.entries.is_empty()
} }
/// Remove all entries from the cache.
pub fn clear(&mut self) {
self.entries.clear();
}
} }
impl<V> Default for EntityCache<V> { impl<V> Default for EntityCache<V> {
@@ -236,4 +260,79 @@ mod tests {
assert_eq!(cache.get(&mr(42)), Some(&"mr-42")); assert_eq!(cache.get(&mr(42)), Some(&"mr-42"));
assert_eq!(cache.len(), 2); assert_eq!(cache.len(), 2);
} }
#[test]
fn test_get_mut_modifies_in_place() {
let mut cache = EntityCache::with_capacity(4);
cache.put(issue(1), String::from("original"));
if let Some(val) = cache.get_mut(&issue(1)) {
val.push_str("-modified");
}
assert_eq!(
cache.get(&issue(1)),
Some(&String::from("original-modified"))
);
}
#[test]
fn test_get_mut_returns_none_for_missing() {
let mut cache: EntityCache<String> = EntityCache::with_capacity(4);
assert!(cache.get_mut(&issue(99)).is_none());
}
#[test]
fn test_get_mut_bumps_tick_keeps_alive() {
let mut cache = EntityCache::with_capacity(2);
cache.put(issue(1), "a"); // tick 1
cache.put(issue(2), "b"); // tick 2
// Bump issue(1) via get_mut so it survives eviction.
let _ = cache.get_mut(&issue(1)); // tick 3
// Insert a 3rd — should evict issue(2) (tick 2, LRU).
cache.put(issue(3), "c"); // tick 4
assert!(cache.get(&issue(1)).is_some(), "issue(1) should survive");
assert!(cache.get(&issue(2)).is_none(), "issue(2) should be evicted");
assert!(cache.get(&issue(3)).is_some(), "issue(3) just inserted");
}
#[test]
fn test_clear_removes_all_entries() {
let mut cache = EntityCache::with_capacity(8);
cache.put(issue(1), "a");
cache.put(issue(2), "b");
cache.put(mr(3), "c");
assert_eq!(cache.len(), 3);
cache.clear();
assert!(cache.is_empty());
assert_eq!(cache.len(), 0);
assert_eq!(cache.get(&issue(1)), None);
assert_eq!(cache.get(&issue(2)), None);
assert_eq!(cache.get(&mr(3)), None);
}
#[test]
fn test_clear_on_empty_cache_is_noop() {
let mut cache: EntityCache<&str> = EntityCache::with_capacity(4);
cache.clear();
assert!(cache.is_empty());
}
#[test]
fn test_clear_resets_tick_and_allows_reuse() {
let mut cache = EntityCache::with_capacity(4);
cache.put(issue(1), "v1");
cache.put(issue(2), "v2");
cache.clear();
// Cache should work normally after clear.
cache.put(issue(3), "v3");
assert_eq!(cache.get(&issue(3)), Some(&"v3"));
assert_eq!(cache.len(), 1);
}
} }

View File

@@ -33,7 +33,9 @@ pub mod who;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use crate::entity_cache::EntityCache;
use crate::message::Screen; use crate::message::Screen;
use crate::view::common::discussion_tree::DiscussionNode;
// Re-export screen states for convenience. // Re-export screen states for convenience.
pub use bootstrap::BootstrapState; pub use bootstrap::BootstrapState;
@@ -165,6 +167,24 @@ pub struct ScopeContext {
pub project_name: Option<String>, pub project_name: Option<String>,
} }
// ---------------------------------------------------------------------------
// Cached detail payloads
// ---------------------------------------------------------------------------
/// Cached issue detail payload (metadata + discussions).
#[derive(Debug, Clone)]
pub struct CachedIssuePayload {
pub data: issue_detail::IssueDetailData,
pub discussions: Vec<DiscussionNode>,
}
/// Cached MR detail payload (metadata + discussions).
#[derive(Debug, Clone)]
pub struct CachedMrPayload {
pub data: mr_detail::MrDetailData,
pub discussions: Vec<DiscussionNode>,
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// AppState // AppState
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -199,6 +219,10 @@ pub struct AppState {
pub error_toast: Option<String>, pub error_toast: Option<String>,
pub show_help: bool, pub show_help: bool,
pub terminal_size: (u16, u16), pub terminal_size: (u16, u16),
// Entity caches for near-instant detail view reopens.
pub issue_cache: EntityCache<CachedIssuePayload>,
pub mr_cache: EntityCache<CachedMrPayload>,
} }
impl AppState { impl AppState {

View File

@@ -371,7 +371,11 @@ fn render_mr_list(
// Inline discussion snippets (rendered beneath MRs when toggled on). // Inline discussion snippets (rendered beneath MRs when toggled on).
if state.show_discussions && !result.discussions.is_empty() { 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 disc_start_y = start_y + visible_mrs as u16;
let remaining = height.saturating_sub(visible_mrs); let remaining = height.saturating_sub(visible_mrs);
render_discussions(frame, result, x, disc_start_y, max_x, remaining, bp); 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); y = render_metadata_row(frame, meta, bp, area.x, y, max_x);
// --- Optional milestone / due date row (skip on Xs — too narrow) --- // --- Optional milestone / due date row (skip on Xs — too narrow) ---
if !matches!(bp, Breakpoint::Xs) if !matches!(bp, Breakpoint::Xs) && (meta.milestone.is_some() || meta.due_date.is_some()) {
&& (meta.milestone.is_some() || meta.due_date.is_some())
{
y = render_milestone_row(frame, meta, area.x, y, max_x); 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 xref_count = state.cross_refs.len();
let wide = detail_side_panel(bp); 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 --- // --- Description section ---
if desc_h > 0 { if desc_h > 0 {
@@ -629,7 +628,10 @@ mod tests {
fn test_allocate_sections_wide_gives_more_description() { fn test_allocate_sections_wide_gives_more_description() {
let (d_narrow, _, _) = allocate_sections(20, 10, 3, 2, false); let (d_narrow, _, _) = allocate_sections(20, 10, 3, 2, false);
let (d_wide, _, _) = allocate_sections(20, 10, 3, 2, true); 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] #[test]

View File

@@ -102,7 +102,15 @@ pub fn render_search(frame: &mut Frame<'_>, state: &SearchState, area: Rect) {
if state.results.is_empty() { if state.results.is_empty() {
render_empty_state(frame, state, area.x + 1, y, max_x); render_empty_state(frame, state, area.x + 1, y, max_x);
} else { } 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 ----------------------------------------------------- // -- 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 bar_start_y = area.y + 4;
let label_width = 14u16; // "Discussions " is the longest let label_width = 14u16; // "Discussions " is the longest
let bar_x = area.x + 2 + label_width; let bar_x = area.x + 2 + label_width;
let bar_width = area let bar_width = area.width.saturating_sub(4 + label_width + 12).min(max_bar); // Cap bar width for very wide terminals
.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() { for (i, lane) in SyncLane::ALL.iter().enumerate() {
let y = bar_start_y + i as u16; let y = bar_start_y + i as u16;

View File

@@ -124,7 +124,16 @@ pub fn render_timeline(
} else { } else {
let bp = classify_width(area.width); let bp = classify_width(area.width);
let time_col_width = timeline_time_width(bp); 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 -- // -- Hint bar --

View File

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