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

@@ -40,13 +40,15 @@ pub fn fetch_trace(
pub fn fetch_known_paths(conn: &Connection, project_id: Option<i64>) -> Result<Vec<String>> {
let paths = if let Some(pid) = project_id {
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))?;
rows.collect::<std::result::Result<Vec<_>, _>>()?
} else {
let mut stmt =
conn.prepare("SELECT DISTINCT new_path FROM mr_file_changes ORDER BY new_path")?;
let mut stmt = conn.prepare(
"SELECT DISTINCT new_path FROM mr_file_changes ORDER BY new_path LIMIT 5000",
)?;
let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
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 crate::crash_context::CrashEvent;
use crate::message::{InputMode, Msg, Screen};
use crate::state::LoadState;
use crate::message::{EntityKind, InputMode, Msg, Screen};
use crate::state::{CachedIssuePayload, CachedMrPayload, LoadState};
use crate::task_supervisor::TaskKey;
use super::LoreApp;
@@ -263,6 +263,10 @@ impl LoreApp {
// -----------------------------------------------------------------------
/// 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> {
let screen_label = screen.label().to_string();
let current_label = self.navigation.current().label().to_string();
@@ -274,21 +278,56 @@ impl LoreApp {
self.navigation.push(screen.clone());
// First visit → full-screen spinner; revisit → corner spinner over stale data.
let load_state = if self.state.load_state.was_visited(&screen) {
// Check entity cache for detail views — apply cached data instantly.
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
} else {
LoadState::LoadingInitial
};
self.state.set_loading(screen.clone(), load_state);
// Spawn supervised task for data loading (placeholder — actual DB
// query dispatch comes in Phase 2 screen implementations).
// Always spawn a data load (even on cache hit, to ensure freshness).
let _handle = self.supervisor.submit(TaskKey::LoadScreen(screen));
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)
// -----------------------------------------------------------------------
@@ -397,6 +436,14 @@ impl LoreApp {
.supervisor
.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.set_loading(screen.clone(), LoadState::Idle);
self.supervisor
@@ -413,14 +460,24 @@ impl LoreApp {
// supervisor.complete(), so is_current() would return false.
// Instead, check that the detail state still expects this key.
match key.kind {
crate::message::EntityKind::Issue => {
EntityKind::Issue => {
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) {
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
.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.set_loading(screen.clone(), LoadState::Idle);
self.supervisor

View File

@@ -25,6 +25,15 @@ pub struct EntityCache<V> {
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> {
/// Create a new cache with the default capacity (64).
#[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.
pub fn put(&mut self, key: EntityKey, value: V) {
self.tick += 1;
@@ -103,6 +122,11 @@ impl<V> EntityCache<V> {
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// Remove all entries from the cache.
pub fn clear(&mut self) {
self.entries.clear();
}
}
impl<V> Default for EntityCache<V> {
@@ -236,4 +260,79 @@ mod tests {
assert_eq!(cache.get(&mr(42)), Some(&"mr-42"));
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 crate::entity_cache::EntityCache;
use crate::message::Screen;
use crate::view::common::discussion_tree::DiscussionNode;
// Re-export screen states for convenience.
pub use bootstrap::BootstrapState;
@@ -165,6 +167,24 @@ pub struct ScopeContext {
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
// ---------------------------------------------------------------------------
@@ -199,6 +219,10 @@ pub struct AppState {
pub error_toast: Option<String>,
pub show_help: bool,
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 {

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);
}
}
}