feat(frecency): initial frecency support

This commit is contained in:
lalvarezt 2025-07-27 20:29:52 +02:00
parent 83f29f7418
commit 26db5a95d0
10 changed files with 725 additions and 22 deletions

View File

@ -491,13 +491,13 @@ pub fn draw(c: &mut Criterion) {
tv.find("television");
for _ in 0..5 {
// tick the matcher
let _ = tv.channel.results(10, 0);
let _ = tv.channel.results(10, 0, None);
std::thread::sleep(std::time::Duration::from_millis(10));
}
tv.move_cursor(Movement::Next, 10);
let selected_entry = tv.get_selected_entry();
let _ = tv.update_preview_state(&selected_entry);
let _ = tv.update(&Action::Tick);
let _ = tv.update(&Action::Tick, None);
(tv, terminal)
},
// Measurement

View File

@ -4,7 +4,7 @@
.SH NAME
television \- Cross\-platform, fast and extensible general purpose fuzzy finder TUI.
.SH SYNOPSIS
\fBtelevision\fR [\fB\-\-preview\-offset\fR] [\fB\-\-no\-preview\fR] [\fB\-\-hide\-preview\fR] [\fB\-\-show\-preview\fR] [\fB\-\-no\-status\-bar\fR] [\fB\-\-hide\-status\-bar\fR] [\fB\-\-show\-status\-bar\fR] [\fB\-t\fR|\fB\-\-tick\-rate\fR] [\fB\-\-watch\fR] [\fB\-k\fR|\fB\-\-keybindings\fR] [\fB\-i\fR|\fB\-\-input\fR] [\fB\-\-input\-header\fR] [\fB\-\-input\-prompt\fR] [\fB\-\-preview\-header\fR] [\fB\-\-preview\-footer\fR] [\fB\-s\fR|\fB\-\-source\-command\fR] [\fB\-\-ansi\fR] [\fB\-\-source\-display\fR] [\fB\-\-source\-output\fR] [\fB\-\-source\-entry\-delimiter\fR] [\fB\-p\fR|\fB\-\-preview\-command\fR] [\fB\-\-layout\fR] [\fB\-\-autocomplete\-prompt\fR] [\fB\-\-exact\fR] [\fB\-\-select\-1\fR] [\fB\-\-take\-1\fR] [\fB\-\-take\-1\-fast\fR] [\fB\-\-no\-remote\fR] [\fB\-\-hide\-remote\fR] [\fB\-\-show\-remote\fR] [\fB\-\-no\-help\-panel\fR] [\fB\-\-hide\-help\-panel\fR] [\fB\-\-show\-help\-panel\fR] [\fB\-\-ui\-scale\fR] [\fB\-\-preview\-size\fR] [\fB\-\-config\-file\fR] [\fB\-\-cable\-dir\fR] [\fB\-\-global\-history\fR] [\fB\-\-height\fR] [\fB\-\-width\fR] [\fB\-\-inline\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR] [\fICHANNEL\fR] [\fIPATH\fR] [\fIsubcommands\fR]
\fBtelevision\fR [\fB\-\-preview\-offset\fR] [\fB\-\-no\-preview\fR] [\fB\-\-hide\-preview\fR] [\fB\-\-show\-preview\fR] [\fB\-\-no\-status\-bar\fR] [\fB\-\-hide\-status\-bar\fR] [\fB\-\-show\-status\-bar\fR] [\fB\-t\fR|\fB\-\-tick\-rate\fR] [\fB\-\-watch\fR] [\fB\-k\fR|\fB\-\-keybindings\fR] [\fB\-\-expect\fR] [\fB\-i\fR|\fB\-\-input\fR] [\fB\-\-input\-header\fR] [\fB\-\-input\-prompt\fR] [\fB\-\-input\-border\fR] [\fB\-\-input\-padding\fR] [\fB\-\-preview\-header\fR] [\fB\-\-preview\-footer\fR] [\fB\-\-preview\-border\fR] [\fB\-\-preview\-padding\fR] [\fB\-s\fR|\fB\-\-source\-command\fR] [\fB\-\-ansi\fR] [\fB\-\-source\-display\fR] [\fB\-\-source\-output\fR] [\fB\-\-results\-border\fR] [\fB\-\-results\-padding\fR] [\fB\-\-source\-entry\-delimiter\fR] [\fB\-p\fR|\fB\-\-preview\-command\fR] [\fB\-\-layout\fR] [\fB\-\-autocomplete\-prompt\fR] [\fB\-\-exact\fR] [\fB\-\-select\-1\fR] [\fB\-\-take\-1\fR] [\fB\-\-take\-1\-fast\fR] [\fB\-\-no\-remote\fR] [\fB\-\-hide\-remote\fR] [\fB\-\-show\-remote\fR] [\fB\-\-no\-help\-panel\fR] [\fB\-\-hide\-help\-panel\fR] [\fB\-\-show\-help\-panel\fR] [\fB\-\-ui\-scale\fR] [\fB\-\-preview\-size\fR] [\fB\-\-config\-file\fR] [\fB\-\-cable\-dir\fR] [\fB\-\-global\-history\fR] [\fB\-\-frecency\fR] [\fB\-\-global\-frecency\fR] [\fB\-\-height\fR] [\fB\-\-width\fR] [\fB\-\-inline\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR] [\fICHANNEL\fR] [\fIPATH\fR] [\fIsubcommands\fR]
.SH DESCRIPTION
Cross\-platform, fast and extensible general purpose fuzzy finder TUI.
.SH OPTIONS
@ -84,6 +84,16 @@ expressions using the configuration file formalism.
Example: `tv \-\-keybindings=\*(Aqquit="esc";select_next_entry=["down","ctrl\-j"]\*(Aq`
.TP
\fB\-\-expect\fR=\fISTRING\fR
Keys that can be used to confirm the current selection in addition to the default ones
(typically `enter`).
When this is set, confirming the selection will first output an extra line with the key
that was used to confirm the selection before outputting the selected entry.
Example: `tv \-\-expect=\*(Aqctrl\-q\*(Aq` will output `ctr\-q\\n<selected_entry>` when `ctrl\-q` is
pressed to confirm the selection.
.TP
\fB\-i\fR, \fB\-\-input\fR=\fISTRING\fR
Input text to pass to the channel to prefill the prompt.
@ -111,6 +121,22 @@ When no channel is specified: Sets the input prompt for the ad\-hoc channel.
The given value is used as the prompt string shown before the input field.
Defaults to ">" when omitted.
.TP
\fB\-\-input\-border\fR=\fIINPUT_BORDER\fR
Sets the input panel border type.
Available options are: `none`, `plain`, `rounded`, `thick`.
.br
.br
[\fIpossible values: \fRnone, plain, rounded, thick]
.TP
\fB\-\-input\-padding\fR=\fISTRING\fR
Sets the input panel padding.
Format: `top=INTEGER;left=INTEGER;bottom=INTEGER;right=INTEGER`
Example: `\-\-input\-padding=\*(Aqtop=1;left=2;bottom=1;right=2\*(Aq`
.TP
\fB\-\-preview\-header\fR=\fISTRING\fR
Preview header template
@ -129,6 +155,22 @@ When no channel is specified: This flag requires \-\-preview\-command to be set.
The given value is parsed as a `MultiTemplate`. It is evaluated for every
entry and its result is displayed below the preview panel.
.TP
\fB\-\-preview\-border\fR=\fIPREVIEW_BORDER\fR
Sets the preview panel border type.
Available options are: `none`, `plain`, `rounded`, `thick`.
.br
.br
[\fIpossible values: \fRnone, plain, rounded, thick]
.TP
\fB\-\-preview\-padding\fR=\fISTRING\fR
Sets the preview panel padding.
Format: `top=INTEGER;left=INTEGER;bottom=INTEGER;right=INTEGER`
Example: `\-\-preview\-padding=\*(Aqtop=1;left=2;bottom=1;right=2\*(Aq`
.TP
\fB\-s\fR, \fB\-\-source\-command\fR=\fISTRING\fR
Source command to use for the current channel.
@ -164,6 +206,22 @@ When no channel is specified: This flag requires \-\-source\-command to be set.
The template is used to format the final output when an entry is selected.
Example: "{}" (output the full entry)
.TP
\fB\-\-results\-border\fR=\fIRESULTS_BORDER\fR
Sets the results panel border type.
Available options are: `none`, `plain`, `rounded`, `thick`.
.br
.br
[\fIpossible values: \fRnone, plain, rounded, thick]
.TP
\fB\-\-results\-padding\fR=\fISTRING\fR
Sets the results panel padding.
Format: `top=INTEGER;left=INTEGER;bottom=INTEGER;right=INTEGER`
Example: `\-\-results\-padding=\*(Aqtop=1;left=2;bottom=1;right=2\*(Aq`
.TP
\fB\-\-source\-entry\-delimiter\fR=\fISTRING\fR
The delimiter byte to use for splitting the source\*(Aqs command output into entries.
@ -318,6 +376,22 @@ This flag only works in channel mode.
When enabled, history navigation will show entries from all channels.
When disabled (default), history navigation is scoped to the current channel.
.TP
\fB\-\-frecency\fR
Enable frecency scoring to boost previously selected entries.
This flag works in both channel mode and ad\-hoc mode.
When enabled, entries that were previously selected will be ranked higher
in the results list based on frequency of use and recency of access.
.TP
\fB\-\-global\-frecency\fR
Use global frecency across all channels instead of channel\-specific frecency.
This flag only works when \-\-frecency is enabled.
When enabled, frecency scoring will consider selections from all channels.
When disabled (default), frecency is scoped to the current channel.
.TP
\fB\-\-height\fR=\fIINTEGER\fR
Height in lines for non\-fullscreen mode.

View File

@ -5,6 +5,7 @@ use crate::{
cli::PostProcessedCli,
config::{Config, DEFAULT_PREVIEW_SIZE, default_tick_rate},
event::{Event, EventLoop, InputEvent, Key, MouseInputEvent},
frecency::Frecency,
history::History,
keymap::InputMap,
render::{RenderingTask, UiState, render},
@ -13,6 +14,7 @@ use crate::{
};
use anyhow::Result;
use rustc_hash::FxHashSet;
use std::path::Path;
use tokio::sync::mpsc;
use tracing::{debug, error, trace};
@ -138,6 +140,8 @@ pub struct App {
watch_timer_task: Option<tokio::task::JoinHandle<()>>,
/// Global history for selected entries
history: History,
/// Frecency tracking for selected entries
frecency: Frecency,
}
/// The outcome of an action.
@ -223,6 +227,16 @@ impl App {
error!("Failed to initialize history: {}", e);
}
// Initialize frecency
let mut frecency = Self::create_frecency(
&television.cli_args,
&television.channel_prototype,
&television.config.application.data_dir,
);
if let Err(e) = frecency.init() {
error!("Failed to initialize frecency: {}", e);
}
let mut app = Self {
input_map,
television,
@ -240,6 +254,7 @@ impl App {
options,
watch_timer_task: None,
history,
frecency,
};
// populate input_map by going through all cable channels and adding their shortcuts if remote
@ -336,6 +351,18 @@ impl App {
&channel_prototype.metadata.name,
global_mode,
);
// Determine the effective global_frecency (channel overrides global config)
let frecency_global = channel_prototype
.frecency
.global_mode
.unwrap_or(self.television.cli_args.global_frecency);
// Update existing frecency with new channel context
self.frecency.update_channel_context(
&channel_prototype.metadata.name,
frecency_global,
);
}
/// Run the application main loop.
@ -463,6 +490,11 @@ impl App {
error!("Failed to persist history: {}", e);
}
// persist frecency data
if let Err(e) = self.frecency.save_to_file() {
error!("Failed to persist frecency: {}", e);
}
// wait for the rendering task to finish
if let Some(rendering_task) = self.render_task.take() {
rendering_task.await?.expect("Rendering task failed");
@ -605,6 +637,20 @@ impl App {
query,
self.television.current_channel(),
)?;
// Add selected entries to frecency
for entry in &entries {
if let Err(e) = self.frecency.add_entry(
entry.raw.clone(),
self.television.current_channel(),
) {
error!(
"Failed to add entry to frecency: {}",
e
);
}
}
return Ok(ActionOutcome::Entries(entries));
}
@ -627,6 +673,20 @@ impl App {
query,
self.television.current_channel(),
)?;
// Add selected entries to frecency
for entry in &entries {
if let Err(e) = self.frecency.add_entry(
entry.raw.clone(),
self.television.current_channel(),
) {
error!(
"Failed to add entry to frecency: {}",
e
);
}
}
return Ok(ActionOutcome::EntriesWithExpect(
entries, k,
));
@ -676,7 +736,14 @@ impl App {
self.television.mode == Mode::RemoteControl;
// forward action to the television handler
if let Some(action) = self.television.update(&action)? {
let frecency_ref = if self.frecency.max_size() > 0 {
Some(&self.frecency)
} else {
None
};
if let Some(action) =
self.television.update(&action, frecency_ref)?
{
self.action_tx.send(action)?;
}
@ -745,6 +812,34 @@ impl App {
}
}
/// Create frecency instance
fn create_frecency(
cli_args: &PostProcessedCli,
channel_prototype: &ChannelPrototype,
data_dir: &Path,
) -> Frecency {
// Determine if frecency is enabled (CLI overrides channel config)
let frecency_enabled = cli_args.frecency
|| channel_prototype.frecency.enabled.unwrap_or(false);
// Determine global mode (CLI overrides channel config)
let frecency_global = cli_args.global_frecency
|| channel_prototype.frecency.global_mode.unwrap_or(false);
let frecency_size = if frecency_enabled {
crate::frecency::DEFAULT_FRECENCY_SIZE
} else {
0
};
Frecency::new(
frecency_size,
&channel_prototype.metadata.name,
frecency_global,
data_dir,
)
}
/// Determine the TUI mode based on the provided options.
fn determine_tui_mode(
height: Option<u16>,

View File

@ -3,15 +3,20 @@ use crate::{
entry::Entry,
prototypes::{ChannelPrototype, SourceSpec, Template},
},
matcher::{Matcher, config::Config, injector::Injector},
frecency::Frecency,
matcher::{
Matcher, config::Config, injector::Injector, matched_item::MatchedItem,
},
utils::command::shell_command,
};
use rustc_hash::{FxBuildHasher, FxHashSet};
use std::collections::HashSet;
use std::io::{BufRead, BufReader};
use std::process::Stdio;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
};
use std::time::Duration;
use tracing::{debug, trace};
@ -26,6 +31,8 @@ pub struct Channel {
/// Indicates if the channel is currently reloading to prevent UI flickering
/// by delaying the rendering of a new frame.
pub reloading: Arc<AtomicBool>,
/// Track current dataset items as they're loaded for frecency filtering
current_dataset: Arc<Mutex<FxHashSet<String>>>,
}
impl Channel {
@ -40,26 +47,33 @@ impl Channel {
crawl_handle: None,
current_source_index,
reloading: Arc::new(AtomicBool::new(false)),
current_dataset: Arc::new(Mutex::new(FxHashSet::default())),
}
}
pub fn load(&mut self) {
// Clear the current dataset at the start of each load
if let Ok(mut dataset) = self.current_dataset.lock() {
dataset.clear();
}
let injector = self.matcher.injector();
let current_dataset = self.current_dataset.clone();
let crawl_handle = tokio::spawn(load_candidates(
self.prototype.source.clone(),
self.current_source_index,
injector,
current_dataset,
));
self.crawl_handle = Some(crawl_handle);
}
pub fn reload(&mut self) {
if self.reloading.load(std::sync::atomic::Ordering::Relaxed) {
if self.reloading.load(Ordering::Relaxed) {
debug!("Reload already in progress, skipping.");
return;
}
self.reloading
.store(true, std::sync::atomic::Ordering::Relaxed);
self.reloading.store(true, Ordering::Relaxed);
if let Some(handle) = self.crawl_handle.take() {
if !handle.is_finished() {
@ -73,7 +87,7 @@ impl Channel {
let reloading = self.reloading.clone();
tokio::spawn(async move {
tokio::time::sleep(RELOAD_RENDERING_DELAY).await;
reloading.store(false, std::sync::atomic::Ordering::Relaxed);
reloading.store(false, Ordering::Relaxed);
});
}
@ -89,10 +103,131 @@ impl Channel {
self.matcher.find(pattern);
}
pub fn results(&mut self, num_entries: u32, offset: u32) -> Vec<Entry> {
/// Filter recent items to only include those that exist in the current dataset
fn filter_recent_items_by_current_dataset(
&self,
recent_items: &[String],
) -> Vec<String> {
match self.current_dataset.lock() {
Ok(current_dataset) => {
let mut filtered = Vec::with_capacity(recent_items.len());
filtered.extend(
recent_items
.iter()
.filter(|item| current_dataset.contains(*item))
.cloned(),
);
filtered
}
Err(_) => {
// If we can't lock, return empty to prevent inconsistent results
Vec::new()
}
}
}
/// Fuzzy match against a list of recent items
#[allow(clippy::cast_possible_truncation)]
fn fuzzy_match_recent_items(
pattern: &str,
recent_items: &[String],
) -> Vec<MatchedItem<String>> {
if recent_items.is_empty() {
return Vec::new();
}
// Create a temporary matcher for recent items
let config = Config::default().prefer_prefix(true);
let mut recent_matcher = Matcher::new(&config);
// Inject recent items into the matcher
let injector = recent_matcher.injector();
for item in recent_items {
injector.push(item.clone(), |e, cols| {
cols[0] = e.clone().into();
});
}
// Apply the pattern
recent_matcher.find(pattern);
// Let the matcher process
recent_matcher.tick();
// Get all matches (recent items are small, so we can get all)
recent_matcher.results(recent_items.len() as u32, 0)
}
#[allow(clippy::cast_possible_truncation)]
pub fn results(
&mut self,
num_entries: u32,
offset: u32,
frecency: Option<&Frecency>,
) -> Vec<Entry> {
self.matcher.tick();
let results = self.matcher.results(num_entries, offset);
let results = if let Some(frecency_data) = frecency {
// Frecency-aware results with dataset validation
let recent_items = frecency_data.get_recent_items();
// Early exit if no recent items to avoid unnecessary work
if recent_items.is_empty() {
self.matcher.results(num_entries, offset)
} else {
let filtered_recent_items =
self.filter_recent_items_by_current_dataset(&recent_items);
// If no recent items pass validation, fall back to regular matching
if filtered_recent_items.is_empty() {
self.matcher.results(num_entries, offset)
} else {
// Fuzzy match the validated recent items
let recent_matches = Self::fuzzy_match_recent_items(
&self.matcher.last_pattern,
&filtered_recent_items,
);
// Get regular results, excluding recent matches to avoid duplicates
let remaining_slots = num_entries
.saturating_sub(recent_matches.len() as u32);
let mut regular_matches = Vec::new();
if remaining_slots > 0 {
// Fetch full list to account for deduplication
let nucleo_results =
self.matcher.results(num_entries, 0);
// Use Vec::contains for small recent items list (faster than HashSet creation)
regular_matches.reserve(remaining_slots as usize);
for item in nucleo_results {
if !filtered_recent_items.contains(&item.inner) {
regular_matches.push(item);
if regular_matches.len()
>= remaining_slots as usize
{
break;
}
}
}
}
// Combine with recent items prioritized first
let mut combined = recent_matches;
combined.extend(regular_matches);
// Apply pagination
combined
.into_iter()
.skip(offset as usize)
.take(num_entries as usize)
.collect()
}
}
} else {
// No frecency: use standard nucleo matching
self.matcher.results(num_entries, offset)
};
let mut entries = Vec::with_capacity(results.len());
@ -108,7 +243,9 @@ impl Channel {
}
pub fn get_result(&mut self, index: u32) -> Option<Entry> {
if let Some(item) = self.matcher.get_result(index) {
let item = self.matcher.get_result(index);
if let Some(item) = item {
let mut entry = Entry::new(item.inner.clone())
.with_display(item.matched_string)
.with_match_indices(&item.match_indices);
@ -184,6 +321,7 @@ async fn load_candidates(
source: SourceSpec,
source_command_index: usize,
injector: Injector<String>,
current_dataset: Arc<Mutex<FxHashSet<String>>>,
) {
debug!("Loading candidates from command: {:?}", source.command);
let mut child = shell_command(
@ -228,7 +366,14 @@ async fn load_candidates(
if let Ok(l) = std::str::from_utf8(&buf) {
trace!("Read line: {}", l);
if !l.trim().is_empty() {
let () = injector.push(l.to_string(), |e, cols| {
let entry = l.to_string();
// Track this item in our current dataset
if let Ok(mut dataset) = current_dataset.lock() {
dataset.insert(entry.clone());
}
let () = injector.push(entry, |e, cols| {
if source.ansi {
cols[0] = strip_ansi.format(e).unwrap_or_else(|_| {
panic!(
@ -260,6 +405,11 @@ async fn load_candidates(
for line in reader.lines() {
let line = line.unwrap();
if !line.trim().is_empty() {
// Track this item in our current dataset
if let Ok(mut dataset) = current_dataset.lock() {
dataset.insert(line.clone());
}
let () = injector.push(line, |e, cols| {
cols[0] = e.clone().into();
});
@ -286,8 +436,9 @@ mod tests {
let mut matcher = Matcher::<String>::new(&Config::default());
let injector = matcher.injector();
let current_dataset = Arc::new(Mutex::new(FxHashSet::default()));
load_candidates(source_spec, 0, injector).await;
load_candidates(source_spec, 0, injector, current_dataset).await;
// Check if the matcher has the expected results
matcher.find("test");
@ -309,8 +460,9 @@ mod tests {
let mut matcher = Matcher::<String>::new(&Config::default());
let injector = matcher.injector();
let current_dataset = Arc::new(Mutex::new(FxHashSet::default()));
load_candidates(source_spec, 0, injector).await;
load_candidates(source_spec, 0, injector, current_dataset).await;
// Check if the matcher has the expected results
matcher.find("test");
@ -332,8 +484,9 @@ mod tests {
let mut matcher = Matcher::<String>::new(&Config::default());
let injector = matcher.injector();
let current_dataset = Arc::new(Mutex::new(FxHashSet::default()));
load_candidates(source_spec, 0, injector).await;
load_candidates(source_spec, 0, injector, current_dataset).await;
// Check if the matcher has the expected results
matcher.find("test");

View File

@ -183,6 +183,16 @@ pub struct HistoryConfig {
pub global_mode: Option<bool>,
}
#[derive(Default, Debug, Clone, serde::Deserialize, serde::Serialize)]
pub struct FrecencyConfig {
/// Whether to enable frecency scoring for this channel
#[serde(default)]
pub enabled: Option<bool>,
/// Whether to use global frecency for this channel (overrides global setting)
#[serde(default)]
pub global_mode: Option<bool>,
}
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
pub struct ChannelPrototype {
pub metadata: Metadata,
@ -198,6 +208,8 @@ pub struct ChannelPrototype {
pub watch: f64,
#[serde(default)]
pub history: HistoryConfig,
#[serde(default)]
pub frecency: FrecencyConfig,
// actions: Vec<Action>,
}
@ -228,6 +240,7 @@ impl ChannelPrototype {
keybindings: None,
watch: 0.0,
history: HistoryConfig::default(),
frecency: FrecencyConfig::default(),
}
}
@ -259,6 +272,7 @@ impl ChannelPrototype {
keybindings: None,
watch: 0.0,
history: HistoryConfig::default(),
frecency: FrecencyConfig::default(),
}
}

View File

@ -489,6 +489,24 @@ pub struct Cli {
#[arg(long, verbatim_doc_comment)]
pub global_history: bool,
/// Enable frecency scoring to boost previously selected entries.
///
/// This flag works in both channel mode and ad-hoc mode.
///
/// When enabled, entries that were previously selected will be ranked higher
/// in the results list based on frequency of use and recency of access.
#[arg(long, verbatim_doc_comment)]
pub frecency: bool,
/// Use global frecency across all channels instead of channel-specific frecency.
///
/// This flag only works when --frecency is enabled.
///
/// When enabled, frecency scoring will consider selections from all channels.
/// When disabled (default), frecency is scoped to the current channel.
#[arg(long, verbatim_doc_comment)]
pub global_frecency: bool,
/// Height in lines for non-fullscreen mode.
///
/// This flag works identically in both channel mode and ad-hoc mode.

View File

@ -119,6 +119,10 @@ pub struct PostProcessedCli {
// History configuration
pub global_history: bool,
// Frecency
pub frecency: bool,
pub global_frecency: bool,
// Configuration sources
pub config_file: Option<PathBuf>,
pub cable_dir: Option<PathBuf>,
@ -201,6 +205,10 @@ impl Default for PostProcessedCli {
// History configuration
global_history: false,
// Frecency
frecency: false,
global_frecency: false,
// Configuration sources
config_file: None,
cable_dir: None,
@ -432,6 +440,10 @@ pub fn post_process(cli: Cli, readable_stdin: bool) -> PostProcessedCli {
// History configuration
global_history: cli.global_history,
// Frecency
frecency: cli.frecency,
global_frecency: cli.global_frecency,
// Configuration sources
config_file: cli.config_file.map(|p| expand_tilde(&p)),
cable_dir: cli.cable_dir.map(|p| expand_tilde(&p)),

328
television/frecency.rs Normal file
View File

@ -0,0 +1,328 @@
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::{
path::{Path, PathBuf},
time::{SystemTime, UNIX_EPOCH},
};
use tracing::debug;
const FRECENCY_FILE_NAME: &str = "frecency.json";
const SECONDS_PER_DAY: f64 = 86400.0; // 24 * 60 * 60
pub const DEFAULT_FRECENCY_SIZE: usize = 1000;
/// Maximum number of recent items to prioritize in search results
/// This ensures frequently-used items get higher priority in nucleo matching
pub const RECENT_ITEMS_PRIORITY_COUNT: usize = 200;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FrecencyEntry {
/// The actual selected entry (file path, command, etc.)
pub entry: String,
/// The channel that the entry belongs to
pub channel: String,
/// Number of times this entry was selected
pub access_count: u32,
/// Timestamp of the last access
pub last_access: u64,
}
impl PartialEq for FrecencyEntry {
fn eq(&self, other: &Self) -> bool {
self.entry == other.entry && self.channel == other.channel
}
}
impl FrecencyEntry {
pub fn new(entry: String, channel: String) -> Self {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
Self {
entry,
channel,
access_count: 1,
last_access: timestamp,
}
}
/// Calculate frecency score based on frequency and recency
pub fn calculate_score(&self, now: u64) -> f64 {
// let's limit unreasonably high scores when the access is very recent
let days_since_access =
((now - self.last_access) as f64 / SECONDS_PER_DAY).max(0.1);
// Recency weight: more recent = higher score
let recency_weight = 1.0 / days_since_access;
// Frequency weight: logarithmic scaling to avoid runaway scores
let frequency_weight = f64::from(self.access_count).ln_1p();
// Combined score with recency having stronger influence for recent items
recency_weight * frequency_weight
}
/// Update access information
pub fn update_access(&mut self) {
self.access_count += 1;
self.last_access = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
}
}
#[derive(Debug, Clone)]
pub struct Frecency {
entries: Vec<FrecencyEntry>,
max_size: usize,
file_path: PathBuf,
current_channel: String,
global_mode: bool,
}
impl Frecency {
pub fn new(
max_size: usize,
channel_name: &str,
global_mode: bool,
data_dir: &Path,
) -> Self {
let file_path = data_dir.join(FRECENCY_FILE_NAME);
Self {
entries: Vec::with_capacity(max_size),
max_size,
file_path,
current_channel: channel_name.to_string(),
global_mode,
}
}
/// Initialize the frecency by loading previously persisted entries from disk.
pub fn init(&mut self) -> Result<()> {
// if max_size is 0, frecency is disabled
if self.max_size > 0 {
self.load_from_file()?;
}
Ok(())
}
/// Add or update a frecency entry for a selected item.
pub fn add_entry(&mut self, entry: String, channel: String) -> Result<()> {
if self.max_size == 0 {
return Ok(());
}
// Don't add empty entries
if entry.trim().is_empty() {
return Ok(());
}
// Check if entry already exists
if let Some(existing) = self
.entries
.iter_mut()
.find(|e| e.entry == entry && e.channel == channel)
{
existing.update_access();
} else {
// Add new entry
let frecency_entry = FrecencyEntry::new(entry, channel);
self.entries.push(frecency_entry);
// Trim if exceeding max size - remove oldest entries
if self.entries.len() > self.max_size {
// Sort by last_access and remove the oldest
self.entries.sort_by_key(|e| e.last_access);
self.entries.drain(0..self.entries.len() - self.max_size);
}
}
Ok(())
}
/// Get frecency score for a specific entry (immutable version for sorting)
pub fn get_score(&self, entry: &str) -> Option<f64> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
// Cache channel filter calculation
let channel_filter = if self.global_mode {
None
} else {
Some(self.current_channel.as_str())
};
self.entries
.iter()
.find(|e| {
e.entry == entry
&& channel_filter.is_none_or(|ch| e.channel == ch)
})
.map(|e| e.calculate_score(now))
}
/// Check if an entry has frecency data (was previously selected)
pub fn has_entry(&self, entry: &str) -> bool {
// Cache channel filter calculation
let channel_filter = if self.global_mode {
None
} else {
Some(self.current_channel.as_str())
};
self.entries.iter().any(|e| {
e.entry == entry && channel_filter.is_none_or(|ch| e.channel == ch)
})
}
/// Get all frecency entries sorted by score (highest first)
pub fn get_sorted_entries(&self) -> Vec<(String, f64)> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
// Cache channel filter calculation
let channel_filter = if self.global_mode {
None
} else {
Some(self.current_channel.as_str())
};
let mut entries = Vec::with_capacity(self.entries.len());
entries.extend(
self.entries
.iter()
.filter(|e| channel_filter.is_none_or(|ch| e.channel == ch))
.map(|e| (e.entry.clone(), e.calculate_score(now))),
);
entries.sort_by(|a, b| {
b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)
});
entries
}
/// Get the most recent items for priority matching
/// Returns up to `RECENT_ITEMS_PRIORITY_COUNT` items sorted by recency (newest first)
pub fn get_recent_items(&self) -> Vec<String> {
// Early exit if frecency is disabled or no entries
if self.max_size == 0 || self.entries.is_empty() {
return Vec::new();
}
// Cache channel filter calculation
let channel_filter = if self.global_mode {
None
} else {
Some(self.current_channel.as_str())
};
let mut recent_entries: Vec<_> =
Vec::with_capacity(RECENT_ITEMS_PRIORITY_COUNT);
recent_entries.extend(
self.entries
.iter()
.filter(|e| channel_filter.is_none_or(|ch| e.channel == ch)),
);
// Early exit if no entries match the channel filter
if recent_entries.is_empty() {
return Vec::new();
}
// Sort by last_access timestamp (newest first)
recent_entries.sort_by(|a, b| b.last_access.cmp(&a.last_access));
// Take the most recent items up to the configured limit
let mut result = Vec::with_capacity(
RECENT_ITEMS_PRIORITY_COUNT.min(recent_entries.len()),
);
result.extend(
recent_entries
.into_iter()
.take(RECENT_ITEMS_PRIORITY_COUNT)
.map(|e| e.entry.clone()),
);
result
}
fn load_from_file(&mut self) -> Result<()> {
if !self.file_path.exists() {
debug!("Frecency file not found: {}", self.file_path.display());
return Ok(());
}
let content = std::fs::read_to_string(&self.file_path)?;
if content.trim().is_empty() {
debug!("Frecency file is empty: {}", self.file_path.display());
return Ok(());
}
let mut loaded_entries: Vec<FrecencyEntry> =
serde_json::from_str(&content)?;
// Keep only the most recent entries if file is too large
if loaded_entries.len() > self.max_size {
loaded_entries.sort_by_key(|e| e.last_access);
loaded_entries.drain(0..loaded_entries.len() - self.max_size);
}
self.entries = loaded_entries;
Ok(())
}
pub fn save_to_file(&self) -> Result<()> {
if self.max_size == 0 {
debug!("Frecency is disabled, not saving to file.");
return Ok(());
}
if let Some(parent) = self.file_path.parent() {
std::fs::create_dir_all(parent)?;
}
let json_content = serde_json::to_string_pretty(&self.entries)?;
std::fs::write(&self.file_path, json_content)?;
Ok(())
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn max_size(&self) -> usize {
self.max_size
}
pub fn current_channel(&self) -> &str {
&self.current_channel
}
pub fn global_mode(&self) -> bool {
self.global_mode
}
/// Get all entries in the frecency store.
pub fn get_entries(&self) -> &[FrecencyEntry] {
&self.entries
}
/// Update the current channel context for this frecency instance.
pub fn update_channel_context(
&mut self,
channel_name: &str,
global_mode: bool,
) {
self.current_channel = channel_name.to_string();
self.global_mode = global_mode;
}
}

View File

@ -8,6 +8,7 @@ pub mod draw;
pub mod errors;
pub mod event;
pub mod features;
pub mod frecency;
pub mod gh;
pub mod history;
pub mod input;

View File

@ -12,6 +12,7 @@ use crate::{
draw::{ChannelState, Ctx, TvState},
errors::os_error_exit,
features::FeatureFlags,
frecency::Frecency,
input::convert_action_to_input_request,
picker::{Movement, Picker},
previewer::{
@ -583,7 +584,10 @@ impl Television {
Ok(())
}
pub fn update_results_picker_state(&mut self) {
pub fn update_results_picker_state(
&mut self,
frecency: Option<&Frecency>,
) {
if self.results_picker.selected().is_none()
&& self.channel.result_count() > 0
{
@ -601,7 +605,7 @@ impl Television {
// Re-use the existing allocation instead of constructing a new
// `Vec` every tick:
entries.clear();
entries.extend(self.channel.results(height, offset));
entries.extend(self.channel.results(height, offset, frecency));
}
self.results_picker.total_items = self.channel.result_count();
}
@ -862,10 +866,14 @@ impl Television {
/// Update the television state based on the action provided.
///
/// This function may return an Action that'll be processed by the parent `App`.
pub fn update(&mut self, action: &Action) -> Result<Option<Action>> {
pub fn update(
&mut self,
action: &Action,
frecency: Option<&Frecency>,
) -> Result<Option<Action>> {
self.handle_action(action)?;
self.update_results_picker_state();
self.update_results_picker_state(frecency);
if self.remote_control.is_some() {
self.update_rc_picker_state();