feat(preview): previews can now be cached on a per-channel basis (#667)

Fixes #660

## 📺 PR Description

**This PR adds a cache mechanism for previews that can be set on a
per-channel basis.**

**Typical use case**: your preview is making an expensive call (e.g. API
call, heavily computational, etc.)

This introduces a new `cache` option inside each channel's preview
specification:
```toml
[metadata]
name = "test"

[source]
command = "echo 'test1\\ntest2\\ntest3\\ntest4\\ntest5'"

[preview]
command = "sleep 1 && echo 'Previewing {}'"
cached = true
```

## Checklist

- [x] my commits **and PR title** follow the [conventional
commits](https://www.conventionalcommits.org/en/v1.0.0/#summary) format
- [x] if this is a new feature, I have added tests to consolidate the
feature and prevent regressions
- [ ] if this is a bug fix, I have added a test that reproduces the bug
(if applicable)
- [x] I have added a reasonable amount of documentation to the code
where appropriate
This commit is contained in:
Alex Pasmantier 2025-07-23 00:20:52 +02:00 committed by GitHub
parent a61f26197e
commit be6cdf8a3a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 215 additions and 4 deletions

View File

@ -24,6 +24,9 @@ pub struct Entry {
impl Hash for Entry { impl Hash for Entry {
fn hash<H: Hasher>(&self, state: &mut H) { fn hash<H: Hasher>(&self, state: &mut H) {
self.raw.hash(state); self.raw.hash(state);
if let Some(display) = &self.display {
display.hash(state);
}
if let Some(line_number) = self.line_number { if let Some(line_number) = self.line_number {
line_number.hash(state); line_number.hash(state);
} }
@ -35,6 +38,8 @@ impl PartialEq<Entry> for &Entry {
self.raw == other.raw self.raw == other.raw
&& (self.line_number.is_none() && other.line_number.is_none() && (self.line_number.is_none() && other.line_number.is_none()
|| self.line_number == other.line_number) || self.line_number == other.line_number)
&& (self.display.is_none() && other.display.is_none()
|| self.display == other.display)
} }
} }
@ -43,6 +48,8 @@ impl PartialEq<Entry> for Entry {
self.raw == other.raw self.raw == other.raw
&& (self.line_number.is_none() && other.line_number.is_none() && (self.line_number.is_none() && other.line_number.is_none()
|| self.line_number == other.line_number) || self.line_number == other.line_number)
&& (self.display.is_none() && other.display.is_none()
|| self.display == other.display)
} }
} }

View File

@ -348,11 +348,17 @@ pub struct PreviewSpec {
pub command: CommandSpec, pub command: CommandSpec,
#[serde(default)] #[serde(default)]
pub offset: Option<Template>, pub offset: Option<Template>,
#[serde(default)]
pub cached: bool,
} }
impl PreviewSpec { impl PreviewSpec {
pub fn new(command: CommandSpec, offset: Option<Template>) -> Self { pub fn new(command: CommandSpec, offset: Option<Template>) -> Self {
Self { command, offset } Self {
command,
offset,
cached: false,
}
} }
pub fn from_str_command(command: &str) -> Self { pub fn from_str_command(command: &str) -> Self {
@ -366,6 +372,7 @@ impl PreviewSpec {
env: FxHashMap::default(), env: FxHashMap::default(),
}, },
offset: None, offset: None,
cached: false,
} }
} }
} }

View File

@ -0,0 +1,149 @@
use rustc_hash::FxHashMap;
use crate::channels::entry::Entry;
use crate::previewer::Preview;
use crate::utils::cache::RingSet;
use tracing::debug;
/// Default size of the preview cache: 50 entries.
///
/// This does seem kind of arbitrary for now, will need to play around with it.
/// Assuming a worst case scenario where files are an average of 1 MB this means
/// the cache will never exceed 50 MB which sounds safe enough.
const DEFAULT_CACHE_SIZE: usize = 50;
/// A cache for previews.
/// The cache is implemented as an LRU cache with a fixed size.
#[derive(Debug)]
pub struct Cache {
entries: FxHashMap<Entry, Preview>,
ring_set: RingSet<Entry>,
}
impl Cache {
/// Create a new preview cache with the given capacity.
pub fn new(capacity: usize) -> Self {
Cache {
entries: FxHashMap::default(),
ring_set: RingSet::with_capacity(capacity),
}
}
pub fn get(&self, key: &Entry) -> Option<Preview> {
self.entries.get(key).cloned()
}
/// Insert a new preview into the cache.
/// If the cache is full, the oldest entry will be removed.
/// If the key is already in the cache, the preview will be updated.
pub fn insert(&mut self, key: &Entry, preview: &Preview) {
debug!("Inserting preview into cache for key: {:?}", key);
self.entries.insert(key.clone(), preview.clone());
if let Some(oldest_key) = self.ring_set.push(key.clone()) {
debug!("Cache full, removing oldest entry: {:?}", oldest_key);
self.entries.remove(&oldest_key);
}
}
/// In this context, the size represents the number of occupied slots within the cache.
/// Not to be mistaken with the cache's capacity which is its max size.
pub fn size(&self) -> usize {
self.ring_set.size()
}
pub fn clear(&mut self) {
debug!("Clearing preview cache");
self.entries.clear();
self.ring_set.clear();
}
}
impl Default for Cache {
fn default() -> Self {
Cache::new(DEFAULT_CACHE_SIZE)
}
}
#[cfg(test)]
mod tests {
use devicons::FileIcon;
use ratatui::text::Text;
use super::*;
use crate::channels::entry::Entry;
use crate::previewer::Preview;
#[test]
fn test_preview_cache_ops() {
let mut cache = Cache::new(2);
let entry = Entry::new("test".to_string());
let preview = Preview::default();
cache.insert(&entry, &preview);
assert_eq!(cache.get(&entry).unwrap(), preview);
assert_eq!(cache.size(), 1);
// override cache content for the same key
let mut other_preview = preview.clone();
other_preview.content = Text::raw("some content");
cache.insert(&entry, &other_preview);
assert_eq!(cache.get(&entry).unwrap(), other_preview);
assert_eq!(cache.size(), 1);
// insert new entries to trigger eviction
let new_entry = Entry::new("new_test".to_string());
let new_preview = Preview::default();
cache.insert(&new_entry, &new_preview);
// the two previews should still be available
assert_eq!(cache.size(), 2);
assert_eq!(cache.get(&new_entry).unwrap(), new_preview);
assert_eq!(cache.get(&entry).unwrap(), other_preview);
// this one should trigger eviction
let another_entry = Entry::new("another_test".to_string());
cache.insert(&another_entry, &Preview::default());
assert_eq!(cache.size(), 2);
assert!(cache.get(&entry).is_none());
assert!(cache.get(&new_entry).is_some());
assert!(cache.get(&another_entry).is_some());
assert_eq!(cache.get(&new_entry).unwrap(), Preview::default());
assert_eq!(cache.get(&another_entry).unwrap(), Preview::default());
}
#[test]
fn test_hash_criteria() {
let mut cache = Cache::new(3);
let entry_1 = Entry::new(String::from("entry_1"))
.with_display(String::from("display_1"))
.with_line_number(1)
.with_match_indices(&[1])
.with_icon(FileIcon::default());
let entry_2 = Entry::new(String::from("entry_2"))
.with_display(String::from("display_2"))
.with_line_number(2)
.with_match_indices(&[2])
.with_icon(FileIcon::default());
cache.insert(&entry_1, &Preview::default());
cache.insert(&entry_2, &Preview::default());
assert_eq!(cache.size(), 2);
cache.clear();
assert_eq!(cache.size(), 0);
let mut entry_1_mod = entry_1.clone();
entry_1_mod =
entry_1_mod.with_display(String::from("display_1_modified"));
cache.insert(&entry_1, &Preview::default());
cache.insert(&entry_1_mod, &Preview::default());
assert_eq!(cache.size(), 2);
cache.clear();
assert_eq!(cache.size(), 0);
let mut entry_1_mod = entry_1.clone();
entry_1_mod = entry_1_mod.with_line_number(3);
cache.insert(&entry_1, &Preview::default());
cache.insert(&entry_1_mod, &Preview::default());
assert_eq!(cache.size(), 2);
}
}

View File

@ -1,11 +1,13 @@
use std::{ use std::{
cmp::Ordering, cmp::Ordering,
sync::Arc,
time::{Duration, Instant}, time::{Duration, Instant},
}; };
use ansi_to_tui::IntoText; use ansi_to_tui::IntoText;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use devicons::FileIcon; use devicons::FileIcon;
use parking_lot::Mutex;
use ratatui::text::Text; use ratatui::text::Text;
use tokio::{ use tokio::{
sync::mpsc::{UnboundedReceiver, UnboundedSender}, sync::mpsc::{UnboundedReceiver, UnboundedSender},
@ -18,6 +20,7 @@ use crate::{
entry::Entry, entry::Entry,
prototypes::{CommandSpec, PreviewSpec}, prototypes::{CommandSpec, PreviewSpec},
}, },
previewer::cache::Cache,
utils::{ utils::{
command::shell_command, command::shell_command,
strings::{ strings::{
@ -26,6 +29,7 @@ use crate::{
}, },
}; };
mod cache;
pub mod state; pub mod state;
pub struct Config { pub struct Config {
@ -100,7 +104,7 @@ impl Ticket {
} }
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone, Eq, PartialEq)]
pub struct Preview { pub struct Preview {
pub title: String, pub title: String,
// NOTE: this does couple the previewer with ratatui but allows // NOTE: this does couple the previewer with ratatui but allows
@ -150,21 +154,29 @@ pub struct Previewer {
last_job_entry: Option<Entry>, last_job_entry: Option<Entry>,
preview_spec: PreviewSpec, preview_spec: PreviewSpec,
results: UnboundedSender<Preview>, results: UnboundedSender<Preview>,
cache: Option<Arc<Mutex<Cache>>>,
} }
impl Previewer { impl Previewer {
pub fn new( pub fn new(
preview_command: &PreviewSpec, spec: &PreviewSpec,
config: Config, config: Config,
receiver: UnboundedReceiver<Request>, receiver: UnboundedReceiver<Request>,
sender: UnboundedSender<Preview>, sender: UnboundedSender<Preview>,
cache: bool,
) -> Self { ) -> Self {
let cache = if cache {
Some(Arc::new(Mutex::new(Cache::default())))
} else {
None
};
Self { Self {
config, config,
requests: receiver, requests: receiver,
last_job_entry: None, last_job_entry: None,
preview_spec: preview_command.clone(), preview_spec: spec.clone(),
results: sender, results: sender,
cache,
} }
} }
@ -186,6 +198,7 @@ impl Previewer {
// try to execute the preview with a timeout // try to execute the preview with a timeout
let preview_command = let preview_command =
self.preview_spec.command.clone(); self.preview_spec.command.clone();
let cache = self.cache.clone();
match timeout( match timeout(
self.config.job_timeout, self.config.job_timeout,
tokio::spawn(async move { tokio::spawn(async move {
@ -193,6 +206,7 @@ impl Previewer {
&preview_command, &preview_command,
&ticket.entry, &ticket.entry,
&results_handle, &results_handle,
&cache,
) { ) {
debug!( debug!(
"Failed to generate preview for entry '{}': {}", "Failed to generate preview for entry '{}': {}",
@ -233,9 +247,21 @@ pub fn try_preview(
command: &CommandSpec, command: &CommandSpec,
entry: &Entry, entry: &Entry,
results_handle: &UnboundedSender<Preview>, results_handle: &UnboundedSender<Preview>,
cache: &Option<Arc<Mutex<Cache>>>,
) -> Result<()> { ) -> Result<()> {
debug!("Preview command: {}", command); debug!("Preview command: {}", command);
// Check if the entry is already cached
if let Some(cache) = &cache {
if let Some(preview) = cache.lock().get(entry) {
debug!("Preview for entry '{}' found in cache", entry.raw);
results_handle.send(preview).with_context(
|| "Failed to send cached preview result to main thread.",
)?;
return Ok(());
}
}
let formatted_command = command.get_nth(0).format(&entry.raw)?; let formatted_command = command.get_nth(0).format(&entry.raw)?;
let child = let child =
@ -299,6 +325,13 @@ pub fn try_preview(
) )
} }
}; };
// Cache the preview if caching is enabled
// Note: we're caching errors as well to avoid re-running potentially expensive commands
if let Some(cache) = &cache {
cache.lock().insert(entry, &preview);
debug!("Preview for entry '{}' cached", entry.raw);
}
results_handle results_handle
.send(preview) .send(preview)
.with_context(|| "Failed to send preview result to main thread.") .with_context(|| "Failed to send preview result to main thread.")

View File

@ -256,9 +256,11 @@ impl Television {
let (pv_preview_tx, pv_preview_rx) = unbounded_channel(); let (pv_preview_tx, pv_preview_rx) = unbounded_channel();
let previewer = Previewer::new( let previewer = Previewer::new(
preview_spec, preview_spec,
// NOTE: this could be a per-channel configuration option in the future
PreviewerConfig::default(), PreviewerConfig::default(),
pv_request_rx, pv_request_rx,
pv_preview_tx, pv_preview_tx,
preview_spec.cached,
); );
tokio::spawn(async move { previewer.run().await }); tokio::spawn(async move { previewer.run().await });
Some((pv_request_tx, pv_preview_rx)) Some((pv_request_tx, pv_preview_rx))

View File

@ -113,6 +113,19 @@ where
pub fn back_to_front(&self) -> impl Iterator<Item = T> { pub fn back_to_front(&self) -> impl Iterator<Item = T> {
self.ring_buffer.clone().into_iter().rev() self.ring_buffer.clone().into_iter().rev()
} }
/// Returns the current size of the ring buffer, which is the number of unique keys it
/// contains.
pub fn size(&self) -> usize {
self.known_keys.len()
}
/// Wipes the ring buffer clean.
pub fn clear(&mut self) {
debug!("Clearing ring buffer");
self.ring_buffer.clear();
self.known_keys.clear();
}
} }
#[cfg(test)] #[cfg(test)]