mirror of
https://github.com/alexpasmantier/television.git
synced 2025-07-28 13:51:41 +00:00
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:
parent
a61f26197e
commit
be6cdf8a3a
@ -24,6 +24,9 @@ pub struct Entry {
|
||||
impl Hash for Entry {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
self.raw.hash(state);
|
||||
if let Some(display) = &self.display {
|
||||
display.hash(state);
|
||||
}
|
||||
if let Some(line_number) = self.line_number {
|
||||
line_number.hash(state);
|
||||
}
|
||||
@ -35,6 +38,8 @@ impl PartialEq<Entry> for &Entry {
|
||||
self.raw == other.raw
|
||||
&& (self.line_number.is_none() && other.line_number.is_none()
|
||||
|| 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.line_number.is_none() && other.line_number.is_none()
|
||||
|| self.line_number == other.line_number)
|
||||
&& (self.display.is_none() && other.display.is_none()
|
||||
|| self.display == other.display)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -348,11 +348,17 @@ pub struct PreviewSpec {
|
||||
pub command: CommandSpec,
|
||||
#[serde(default)]
|
||||
pub offset: Option<Template>,
|
||||
#[serde(default)]
|
||||
pub cached: bool,
|
||||
}
|
||||
|
||||
impl PreviewSpec {
|
||||
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 {
|
||||
@ -366,6 +372,7 @@ impl PreviewSpec {
|
||||
env: FxHashMap::default(),
|
||||
},
|
||||
offset: None,
|
||||
cached: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
149
television/previewer/cache.rs
Normal file
149
television/previewer/cache.rs
Normal 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);
|
||||
}
|
||||
}
|
@ -1,11 +1,13 @@
|
||||
use std::{
|
||||
cmp::Ordering,
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use ansi_to_tui::IntoText;
|
||||
use anyhow::{Context, Result};
|
||||
use devicons::FileIcon;
|
||||
use parking_lot::Mutex;
|
||||
use ratatui::text::Text;
|
||||
use tokio::{
|
||||
sync::mpsc::{UnboundedReceiver, UnboundedSender},
|
||||
@ -18,6 +20,7 @@ use crate::{
|
||||
entry::Entry,
|
||||
prototypes::{CommandSpec, PreviewSpec},
|
||||
},
|
||||
previewer::cache::Cache,
|
||||
utils::{
|
||||
command::shell_command,
|
||||
strings::{
|
||||
@ -26,6 +29,7 @@ use crate::{
|
||||
},
|
||||
};
|
||||
|
||||
mod cache;
|
||||
pub mod state;
|
||||
|
||||
pub struct Config {
|
||||
@ -100,7 +104,7 @@ impl Ticket {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
pub struct Preview {
|
||||
pub title: String,
|
||||
// NOTE: this does couple the previewer with ratatui but allows
|
||||
@ -150,21 +154,29 @@ pub struct Previewer {
|
||||
last_job_entry: Option<Entry>,
|
||||
preview_spec: PreviewSpec,
|
||||
results: UnboundedSender<Preview>,
|
||||
cache: Option<Arc<Mutex<Cache>>>,
|
||||
}
|
||||
|
||||
impl Previewer {
|
||||
pub fn new(
|
||||
preview_command: &PreviewSpec,
|
||||
spec: &PreviewSpec,
|
||||
config: Config,
|
||||
receiver: UnboundedReceiver<Request>,
|
||||
sender: UnboundedSender<Preview>,
|
||||
cache: bool,
|
||||
) -> Self {
|
||||
let cache = if cache {
|
||||
Some(Arc::new(Mutex::new(Cache::default())))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Self {
|
||||
config,
|
||||
requests: receiver,
|
||||
last_job_entry: None,
|
||||
preview_spec: preview_command.clone(),
|
||||
preview_spec: spec.clone(),
|
||||
results: sender,
|
||||
cache,
|
||||
}
|
||||
}
|
||||
|
||||
@ -186,6 +198,7 @@ impl Previewer {
|
||||
// try to execute the preview with a timeout
|
||||
let preview_command =
|
||||
self.preview_spec.command.clone();
|
||||
let cache = self.cache.clone();
|
||||
match timeout(
|
||||
self.config.job_timeout,
|
||||
tokio::spawn(async move {
|
||||
@ -193,6 +206,7 @@ impl Previewer {
|
||||
&preview_command,
|
||||
&ticket.entry,
|
||||
&results_handle,
|
||||
&cache,
|
||||
) {
|
||||
debug!(
|
||||
"Failed to generate preview for entry '{}': {}",
|
||||
@ -233,9 +247,21 @@ pub fn try_preview(
|
||||
command: &CommandSpec,
|
||||
entry: &Entry,
|
||||
results_handle: &UnboundedSender<Preview>,
|
||||
cache: &Option<Arc<Mutex<Cache>>>,
|
||||
) -> Result<()> {
|
||||
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 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
|
||||
.send(preview)
|
||||
.with_context(|| "Failed to send preview result to main thread.")
|
||||
|
@ -256,9 +256,11 @@ impl Television {
|
||||
let (pv_preview_tx, pv_preview_rx) = unbounded_channel();
|
||||
let previewer = Previewer::new(
|
||||
preview_spec,
|
||||
// NOTE: this could be a per-channel configuration option in the future
|
||||
PreviewerConfig::default(),
|
||||
pv_request_rx,
|
||||
pv_preview_tx,
|
||||
preview_spec.cached,
|
||||
);
|
||||
tokio::spawn(async move { previewer.run().await });
|
||||
Some((pv_request_tx, pv_preview_rx))
|
||||
|
@ -113,6 +113,19 @@ where
|
||||
pub fn back_to_front(&self) -> impl Iterator<Item = T> {
|
||||
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)]
|
||||
|
Loading…
x
Reference in New Issue
Block a user