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:
337
.beads/.br_history/issues.20260219_052521.jsonl
Normal file
337
.beads/.br_history/issues.20260219_052521.jsonl
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
bd-a6yb
|
||||
bd-3rjw
|
||||
|
||||
@@ -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<_>, _>>()?
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 -----------------------------------------------------
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 --
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user