Merge 36210026f8bc58e8b22cceaf22b8c034135389d3 into 83f29f7418a7adb6f5ca9ee5ac057ef38728fa28

This commit is contained in:
LM 2025-07-28 22:13:25 +02:00 committed by GitHub
commit 58772af0bc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1495 additions and 42 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,19 +3,30 @@ 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, RwLock,
atomic::{AtomicBool, Ordering},
};
use std::time::Duration;
use tokio::sync::mpsc;
use tracing::{debug, trace};
const RELOAD_RENDERING_DELAY: Duration = Duration::from_millis(200);
const DEFAULT_LINE_BUFFER_SIZE: usize = 512;
const DATASET_CHANNEL_CAPACITY: usize = 32;
const DATASET_UPDATE_INTERVAL: Duration = Duration::from_millis(50);
const LOAD_CANDIDATE_BATCH_SIZE: usize = 100;
pub struct Channel {
pub prototype: ChannelPrototype,
@ -26,6 +37,10 @@ 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
current_dataset: Arc<RwLock<FxHashSet<String>>>,
/// Handle for the dataset update task
dataset_update_handle: Option<tokio::task::JoinHandle<()>>,
}
impl Channel {
@ -40,32 +55,55 @@ impl Channel {
crawl_handle: None,
current_source_index,
reloading: Arc::new(AtomicBool::new(false)),
current_dataset: Arc::new(RwLock::new(FxHashSet::default())),
dataset_update_handle: None,
}
}
pub fn load(&mut self) {
// Clear the current dataset at the start of each load
if let Ok(mut dataset) = self.current_dataset.write() {
dataset.clear();
}
// Create bounded channel to prevent unbounded memory growth
let (dataset_tx, dataset_rx) = mpsc::channel(DATASET_CHANNEL_CAPACITY);
// Create dedicated dataset update task
let dataset_clone = self.current_dataset.clone();
let dataset_update_handle = tokio::spawn(async move {
dataset_update_task(dataset_rx, dataset_clone).await;
});
self.dataset_update_handle = Some(dataset_update_handle);
let injector = self.matcher.injector();
let crawl_handle = tokio::spawn(load_candidates(
self.prototype.source.clone(),
self.current_source_index,
injector,
dataset_tx,
));
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);
// Abort existing tasks
if let Some(handle) = self.crawl_handle.take() {
if !handle.is_finished() {
handle.abort();
}
}
if let Some(handle) = self.dataset_update_handle.take() {
if !handle.is_finished() {
handle.abort();
}
}
self.matcher.restart();
self.load();
// Spawn a thread that turns off reloading after a short delay
@ -73,7 +111,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 +127,136 @@ 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],
) -> FxHashSet<String> {
// Try to read dataset, return empty on lock failure to prevent blocking
let Ok(dataset) = self.current_dataset.read() else {
debug!(
"Failed to acquire dataset read lock, skipping frecency filtering"
);
return FxHashSet::default();
};
// Iterate over smaller recent_items (~RECENT_ITEMS_PRIORITY_COUNT)
let mut intersection = FxHashSet::with_capacity_and_hasher(
recent_items.len().min(dataset.len()),
FxBuildHasher,
);
for item in recent_items {
if dataset.contains(item) {
intersection.insert(item.clone());
}
}
intersection
}
/// Fuzzy match against a set of recent items
#[allow(clippy::cast_possible_truncation)]
fn fuzzy_match_recent_items(
pattern: &str,
recent_items: &FxHashSet<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);
// Direct O(1) HashSet lookups for deduplication
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,21 +272,13 @@ impl Channel {
}
pub fn get_result(&mut self, index: u32) -> Option<Entry> {
if let Some(item) = self.matcher.get_result(index) {
let mut entry = Entry::new(item.inner.clone())
.with_display(item.matched_string)
.with_match_indices(&item.match_indices);
if let Some(p) = &self.prototype.preview {
// FIXME: this should be done by the previewer instead
if let Some(offset_expr) = &p.offset {
let offset_str =
offset_expr.format(&item.inner).unwrap_or_default();
let item = self.matcher.get_result(index);
entry = entry.with_line_number(
offset_str.parse::<usize>().unwrap_or(0),
);
}
}
if let Some(item) = item {
let entry = Entry::new(item.inner.clone())
.with_display(item.matched_string)
.with_match_indices(&item.match_indices)
.ansi(self.prototype.source.ansi);
Some(entry)
} else {
None
@ -177,15 +333,97 @@ impl Channel {
}
}
const DEFAULT_LINE_BUFFER_SIZE: usize = 512;
/// Dedicated task for updating the dataset from batched updates
/// This runs independently from the UI to prevent blocking
async fn dataset_update_task(
mut dataset_rx: mpsc::Receiver<Vec<String>>,
current_dataset: Arc<RwLock<FxHashSet<String>>>,
) {
debug!("Starting dataset update task");
let mut update_interval = tokio::time::interval(DATASET_UPDATE_INTERVAL);
let mut pending_updates = Vec::new();
loop {
tokio::select! {
// Receive new batches
batch_result = dataset_rx.recv() => {
if let Some(batch) = batch_result {
pending_updates.push(batch);
} else {
// Channel closed, process remaining updates and exit
if !pending_updates.is_empty() {
apply_pending_updates(&pending_updates, &current_dataset);
}
debug!("Dataset update task exiting - channel closed");
break;
}
}
// Periodic updates to apply accumulated batches
_ = update_interval.tick() => {
if !pending_updates.is_empty() {
apply_pending_updates(&pending_updates, &current_dataset);
pending_updates.clear();
}
}
}
}
}
/// Apply accumulated updates to the dataset with proper error handling
fn apply_pending_updates(
pending_updates: &[Vec<String>],
current_dataset: &Arc<RwLock<FxHashSet<String>>>,
) {
match current_dataset.write() {
Ok(mut dataset) => {
// Pre-calculate capacity to minimize reallocations
let total_items: usize =
pending_updates.iter().map(Vec::len).sum();
dataset.reserve(total_items);
// Apply all pending updates
for batch in pending_updates {
dataset.extend(batch.iter().cloned());
}
trace!(
"Applied {} batches containing {} total items to dataset",
pending_updates.len(),
total_items
);
}
Err(e) => {
debug!(
"Failed to acquire dataset write lock: {:?}. Skipping batch updates.",
e
);
}
}
}
/// Helper function to send a batch if it's not empty with backpressure handling
async fn send_batch_if_not_empty(
batch: &mut Vec<String>,
dataset_tx: &mpsc::Sender<Vec<String>>,
) -> Result<(), mpsc::error::SendError<Vec<String>>> {
if batch.is_empty() {
Ok(())
} else {
let batch_to_send = std::mem::take(batch);
dataset_tx.send(batch_to_send).await
}
}
#[allow(clippy::unused_async)]
async fn load_candidates(
source: SourceSpec,
source_command_index: usize,
injector: Injector<String>,
dataset_tx: mpsc::Sender<Vec<String>>,
) {
debug!("Loading candidates from command: {:?}", source.command);
let mut current_batch = Vec::with_capacity(LOAD_CANDIDATE_BATCH_SIZE);
let mut child = shell_command(
source.command.get_nth(source_command_index).raw(),
source.command.interactive,
@ -228,7 +466,28 @@ 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();
// Add to current batch
current_batch.push(entry.clone());
// Send batch if it reaches the batch size
if current_batch.len() >= LOAD_CANDIDATE_BATCH_SIZE {
if let Err(e) = send_batch_if_not_empty(
&mut current_batch,
&dataset_tx,
)
.await
{
debug!(
"Failed to send dataset batch: {:?}. Dataset may be incomplete.",
e
);
break; // Exit loop if channel is closed
}
}
let () = injector.push(entry, |e, cols| {
if source.ansi {
cols[0] = strip_ansi.format(e).unwrap_or_else(|_| {
panic!(
@ -260,6 +519,25 @@ async fn load_candidates(
for line in reader.lines() {
let line = line.unwrap();
if !line.trim().is_empty() {
// Add to current batch
current_batch.push(line.clone());
// Send batch if it reaches the batch size
if current_batch.len() >= LOAD_CANDIDATE_BATCH_SIZE {
if let Err(e) = send_batch_if_not_empty(
&mut current_batch,
&dataset_tx,
)
.await
{
debug!(
"Failed to send dataset batch: {:?}. Dataset may be incomplete.",
e
);
break; // Exit loop if channel is closed
}
}
let () = injector.push(line, |e, cols| {
cols[0] = e.clone().into();
});
@ -268,6 +546,13 @@ async fn load_candidates(
}
}
let _ = child.wait();
// Send any remaining entries in the final batch
if let Err(e) =
send_batch_if_not_empty(&mut current_batch, &dataset_tx).await
{
debug!("Failed to send final dataset batch: {:?}", e);
}
}
#[cfg(test)]
@ -286,8 +571,10 @@ mod tests {
let mut matcher = Matcher::<String>::new(&Config::default());
let injector = matcher.injector();
let (dataset_tx, _dataset_rx) =
mpsc::channel(DATASET_CHANNEL_CAPACITY);
load_candidates(source_spec, 0, injector).await;
load_candidates(source_spec, 0, injector, dataset_tx).await;
// Check if the matcher has the expected results
matcher.find("test");
@ -309,8 +596,10 @@ mod tests {
let mut matcher = Matcher::<String>::new(&Config::default());
let injector = matcher.injector();
let (dataset_tx, _dataset_rx) =
mpsc::channel(DATASET_CHANNEL_CAPACITY);
load_candidates(source_spec, 0, injector).await;
load_candidates(source_spec, 0, injector, dataset_tx).await;
// Check if the matcher has the expected results
matcher.find("test");
@ -332,8 +621,10 @@ mod tests {
let mut matcher = Matcher::<String>::new(&Config::default());
let injector = matcher.injector();
let (dataset_tx, _dataset_rx) =
mpsc::channel(DATASET_CHANNEL_CAPACITY);
load_candidates(source_spec, 0, injector).await;
load_candidates(source_spec, 0, injector, dataset_tx).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::{
@ -363,12 +364,45 @@ impl Television {
}
pub fn get_selected_entry(&mut self) -> Option<Entry> {
if self.channel.result_count() == 0 {
return None;
match self.mode {
Mode::Channel => {
let entry = self
.results_picker
.selected()
.and_then(|idx| self.results_picker.entries.get(idx))
.cloned()?;
Some(self.apply_preview_offset(entry))
}
Mode::RemoteControl => {
if self
.remote_control
.as_ref()
.map_or(0, RemoteControl::result_count)
== 0
{
return None;
}
let entry = self
.selected_index()
.map(|idx| self.channel.get_result(idx))
.and_then(|entry| entry)?;
Some(self.apply_preview_offset(entry))
}
}
self.selected_index()
.map(|idx| self.channel.get_result(idx))
.and_then(|entry| entry)
}
/// Apply preview offset logic to an entry
fn apply_preview_offset(&self, mut entry: Entry) -> Entry {
if let Some(p) = &self.channel.prototype.preview {
if let Some(offset_expr) = &p.offset {
let offset_str =
offset_expr.format(&entry.raw).unwrap_or_default();
entry = entry.with_line_number(
offset_str.parse::<usize>().unwrap_or(0),
);
}
}
entry
}
pub fn get_selected_cable_entry(&mut self) -> Option<CableEntry> {
@ -583,7 +617,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 +638,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 +899,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();

577
tests/cli/cli_frecency.rs Normal file
View File

@ -0,0 +1,577 @@
//! Tests for CLI frecency behavior: --frecency and --global-frecency flags.
//!
//! These tests verify Television's frecency scoring system that boosts previously
//! selected entries in future searches based on frequency and recency of access.
//! They ensure that selected items appear higher in results lists and that
//! frecency data persists across sessions.
use super::common::*;
use std::fs;
use tempfile::TempDir;
/// Helper function to create a temporary data directory for frecency storage
fn create_temp_data_dir() -> TempDir {
tempfile::tempdir().expect("Failed to create temporary directory")
}
/// Helper function to create a temporary data directory with empty frecency.json
fn create_temp_data_dir_with_empty_frecency() -> TempDir {
let temp_dir = create_temp_data_dir();
let frecency_file = temp_dir.path().join("frecency.json");
// Create empty frecency file to ensure consistent starting state
fs::write(&frecency_file, "[]")
.expect("Failed to create empty frecency file");
temp_dir
}
/// Helper function to create tv command with frecency enabled and custom data directory
fn tv_frecency_with_data_dir(
data_dir: &str,
args: &[&str],
) -> portable_pty::CommandBuilder {
let mut cmd = tv_local_config_and_cable_with_args(args);
cmd.env("TELEVISION_DATA", data_dir);
cmd.arg("--frecency");
cmd
}
/// Tests that frecency ranking works: previously selected entries appear higher in results.
#[test]
fn test_frecency_ranking_boosts_selected_entries() {
let temp_dir = create_temp_data_dir_with_empty_frecency();
let data_dir = temp_dir.path().to_str().unwrap();
// Create test data with multiple entries
let test_entries = ["apple.txt", "banana.txt", "cherry.txt", "date.txt"];
// First session: select "cherry.txt" (third item)
{
let mut tester = PtyTester::new();
let cmd = tv_frecency_with_data_dir(
data_dir,
&[
"--source-command",
&format!("printf '{}'", test_entries.join("\n")),
],
);
let mut child = tester.spawn_command_tui(cmd);
// Wait for UI to load and verify initial order
tester.assert_tui_frame_contains("> apple.txt");
tester.send(&ctrl('n')); // Move down to banana.txt
tester.assert_tui_frame_contains("> banana.txt");
tester.send(&ctrl('n')); // Move down to cherry.txt
tester.assert_tui_frame_contains("> cherry.txt");
// Select cherry.txt
tester.send(ENTER);
// Wait for selection to complete and exit
PtyTester::assert_exit_ok(&mut child, DEFAULT_DELAY);
}
// Second session: verify cherry.txt appears higher due to frecency
{
let mut tester = PtyTester::new();
let cmd = tv_frecency_with_data_dir(
data_dir,
&[
"--source-command",
&format!("printf '{}'", test_entries.join("\n")),
],
);
let mut child = tester.spawn_command_tui(cmd);
// Wait for UI to load and verify initial order
tester.assert_tui_frame_contains("> cherry.txt");
tester.send(&ctrl('n')); // Move down to apple.txt
tester.assert_tui_frame_contains("> apple.txt");
tester.send(&ctrl('n')); // Move down to banana.txt
tester.assert_tui_frame_contains("> banana.txt");
// Exit cleanly
tester.send(&ctrl('c'));
PtyTester::assert_exit_ok(&mut child, DEFAULT_DELAY);
}
}
/// Tests that frecency data persists across multiple sessions.
#[test]
fn test_frecency_persistence_across_sessions() {
let temp_dir = create_temp_data_dir_with_empty_frecency();
let data_dir = temp_dir.path().to_str().unwrap();
let frecency_file = format!("{}/frecency.json", data_dir);
// Create test data with multiple entries
let test_entries = ["persistent.txt", "other.txt"];
// First session: select an entry to create frecency data
{
let mut tester = PtyTester::new();
let cmd = tv_frecency_with_data_dir(
data_dir,
&[
"--source-command",
&format!("printf '{}'", test_entries.join("\n")),
],
);
let mut child = tester.spawn_command_tui(cmd);
// Wait for UI to load and verify initial order
tester.assert_tui_frame_contains("> persistent.txt");
// Select the first entry (persistent.txt)
tester.send(ENTER);
// Wait for selection to complete and exit
PtyTester::assert_exit_ok(&mut child, DEFAULT_DELAY);
}
// Verify frecency file was created
assert!(
std::path::Path::new(&frecency_file).exists(),
"Frecency file should be created at: {}",
frecency_file
);
// Read and verify frecency file contains expected data
let frecency_content = fs::read_to_string(&frecency_file)
.expect("Should be able to read frecency file");
assert!(
frecency_content.contains("persistent.txt"),
"Frecency file should contain selected entry. Content:\n{}",
frecency_content
);
// Second session: verify frecency data is loaded and applied
{
let mut tester = PtyTester::new();
let cmd = tv_frecency_with_data_dir(
data_dir,
&[
"--source-command",
"printf 'other.txt\npersistent.txt'", // Note: reversed order
],
);
let mut child = tester.spawn_command_tui(cmd);
// Wait for UI to load and verify persistent.txt appears first due to frecency
tester.assert_tui_frame_contains("> persistent.txt");
tester.send(&ctrl('n')); // Move down to other.txt
tester.assert_tui_frame_contains("> other.txt");
// Exit cleanly
tester.send(&ctrl('c'));
PtyTester::assert_exit_ok(&mut child, DEFAULT_DELAY);
}
}
/// Tests that frecency only shows entries that exist in the current dataset.
#[test]
fn test_frecency_dataset_filtering() {
let temp_dir = create_temp_data_dir_with_empty_frecency();
let data_dir = temp_dir.path().to_str().unwrap();
// Create test data with multiple entries
let first_session_entries = ["common.txt", "exclusive.txt", "shared.txt"];
let second_session_entries = ["common.txt", "shared.txt", "new.txt"];
// First session: select entries from a larger dataset
{
let mut tester = PtyTester::new();
let cmd = tv_frecency_with_data_dir(
data_dir,
&[
"--source-command",
&format!("printf '{}'", first_session_entries.join("\n")),
],
);
let mut child = tester.spawn_command_tui(cmd);
// Wait for UI to load and verify initial order
tester.assert_tui_frame_contains("> common.txt");
tester.send(&ctrl('n')); // Move down to exclusive.txt
tester.assert_tui_frame_contains("> exclusive.txt");
// Select exclusive.txt (second item)
tester.send(ENTER);
// Wait for selection to complete and exit
PtyTester::assert_exit_ok(&mut child, DEFAULT_DELAY);
}
// Second session: use a dataset that doesn't contain exclusive.txt
{
let mut tester = PtyTester::new();
let cmd = tv_frecency_with_data_dir(
data_dir,
&[
"--source-command",
&format!("printf '{}'", second_session_entries.join("\n")), // exclusive.txt not present
],
);
let mut child = tester.spawn_command_tui(cmd);
// Wait for UI to load and verify only current dataset entries are present
tester.assert_tui_frame_contains("> common.txt");
tester.send(&ctrl('n')); // Move down to shared.txt
tester.assert_tui_frame_contains("> shared.txt");
tester.send(&ctrl('n')); // Move down to new.txt
tester.assert_tui_frame_contains("> new.txt");
let frame = tester.get_tui_frame();
// exclusive.txt should not appear in results since it's not in current dataset
assert!(
!frame.contains("exclusive.txt"),
"exclusive.txt should not appear when not in current dataset. Frame:\n{}",
frame
);
// Exit cleanly
tester.send(&ctrl('c'));
PtyTester::assert_exit_ok(&mut child, DEFAULT_DELAY);
}
}
/// Tests global vs channel-specific frecency behavior.
#[test]
fn test_global_vs_channel_frecency() {
let temp_dir = create_temp_data_dir_with_empty_frecency();
let data_dir = temp_dir.path().to_str().unwrap();
// Create test data with multiple entries
let files_entries = ["file1.txt", "file2.txt"];
let env_entries = ["file2.txt", "env_var"];
// First session: select entry in "files" channel
{
let mut tester = PtyTester::new();
let cmd = tv_frecency_with_data_dir(
data_dir,
&[
"files",
"--source-command",
&format!("printf '{}'", files_entries.join("\n")),
],
);
let mut child = tester.spawn_command_tui(cmd);
// Wait for UI to load and verify initial order
tester.assert_tui_frame_contains("> file1.txt");
tester.send(&ctrl('n')); // Move down to file2.txt
tester.assert_tui_frame_contains("> file2.txt");
// Select file2.txt
tester.send(ENTER);
// Wait for selection to complete and exit
PtyTester::assert_exit_ok(&mut child, DEFAULT_DELAY);
}
// Second session: test channel-specific frecency (default behavior)
{
let mut tester = PtyTester::new();
let cmd = tv_frecency_with_data_dir(
data_dir,
&[
"env", // Different channel
"--source-command",
&format!("printf '{}'", env_entries.join("\n")),
],
);
let mut child = tester.spawn_command_tui(cmd);
// Wait for UI to load and verify natural order (env_var first)
tester.assert_tui_frame_contains("> file2.txt"); // Should be first in natural order
tester.send(&ctrl('n')); // Move down to env_var
tester.assert_tui_frame_contains("> env_var");
let frame = tester.get_tui_frame();
// In channel-specific mode, file2.txt should NOT be boosted since it was selected in different channel
let file2_pos = frame
.find("file2.txt")
.expect("file2.txt should be present");
let env_pos =
frame.find("env_var").expect("env_var should be present");
// file2.txt should appear first (natural order) since frecency doesn't apply across channels
assert!(
file2_pos < env_pos,
"file2.txt should appear first in channel-specific mode. Frame:\n{}",
frame
);
// Exit cleanly
tester.send(&ctrl('c'));
PtyTester::assert_exit_ok(&mut child, DEFAULT_DELAY);
}
// Third session: test global frecency
{
let mut tester = PtyTester::new();
let cmd = tv_frecency_with_data_dir(
data_dir,
&[
"env", // Different channel
"--global-frecency", // Enable global frecency
"--source-command",
&format!("printf '{}'", env_entries.join("\n")),
],
);
let mut child = tester.spawn_command_tui(cmd);
// Wait for UI to load and verify file2.txt appears first due to global frecency
tester.assert_tui_frame_contains("> file2.txt");
tester.send(&ctrl('n')); // Move down to env_var
tester.assert_tui_frame_contains("> env_var");
// Exit cleanly
tester.send(&ctrl('c'));
PtyTester::assert_exit_ok(&mut child, DEFAULT_DELAY);
}
}
/// Tests that frecency handles duplicate entries correctly by incrementing access count.
#[test]
fn test_frecency_duplicate_entry_handling() {
let temp_dir = create_temp_data_dir_with_empty_frecency();
let data_dir = temp_dir.path().to_str().unwrap();
// Create test data with multiple entries
let test_entries = ["duplicate.txt", "other1.txt", "other2.txt"];
// First session: select "duplicate.txt" once
{
let mut tester = PtyTester::new();
let cmd = tv_frecency_with_data_dir(
data_dir,
&[
"--source-command",
&format!("printf '{}'", test_entries.join("\n")),
],
);
let mut child = tester.spawn_command_tui(cmd);
// Wait for UI to load and verify initial order
tester.assert_tui_frame_contains("> duplicate.txt");
// Select duplicate.txt (first time)
tester.send(ENTER);
// Wait for selection to complete and exit
PtyTester::assert_exit_ok(&mut child, DEFAULT_DELAY);
}
// Second session: select "duplicate.txt" again to test increment
{
let mut tester = PtyTester::new();
let cmd = tv_frecency_with_data_dir(
data_dir,
&[
"--source-command",
&format!("printf '{}'", test_entries.join("\n")),
],
);
let mut child = tester.spawn_command_tui(cmd);
// Wait for UI to load and verify duplicate.txt is boosted
tester.assert_tui_frame_contains("> duplicate.txt");
// Select duplicate.txt (second time)
tester.send(ENTER);
// Wait for selection to complete and exit
PtyTester::assert_exit_ok(&mut child, DEFAULT_DELAY);
}
// Verify frecency file shows duplicate.txt has access_count of 2
let frecency_file = format!("{}/frecency.json", data_dir);
let frecency_content = fs::read_to_string(&frecency_file)
.expect("Should be able to read frecency file");
// Parse JSON to verify access count
let frecency_entries: serde_json::Value =
serde_json::from_str(&frecency_content)
.expect("Frecency file should contain valid JSON");
let duplicate_entry = frecency_entries
.as_array()
.expect("Frecency data should be an array")
.iter()
.find(|entry| entry["entry"].as_str() == Some("duplicate.txt"))
.expect("duplicate.txt should be in frecency data");
assert_eq!(
duplicate_entry["access_count"].as_u64(),
Some(2),
"duplicate.txt should have access_count of 2. Entry: {}",
duplicate_entry
);
}
/// Tests frecency behavior when the JSON file is corrupted or invalid.
#[test]
fn test_frecency_corrupted_file_handling() {
let temp_dir = create_temp_data_dir();
let data_dir = temp_dir.path().to_str().unwrap();
let frecency_file = temp_dir.path().join("frecency.json");
// Create corrupted frecency file
fs::write(&frecency_file, "{ invalid json content }")
.expect("Failed to create corrupted frecency file");
// Create test data
let test_entries = ["recovery_test.txt", "normal_entry.txt"];
// First session: should handle corrupted file gracefully
{
let mut tester = PtyTester::new();
let cmd = tv_frecency_with_data_dir(
data_dir,
&[
"--source-command",
&format!("printf '{}'", test_entries.join("\n")),
],
);
let mut child = tester.spawn_command_tui(cmd);
// Wait for UI to load - should work despite corrupted file
tester.assert_tui_frame_contains("> recovery_test.txt");
tester.send(&ctrl('n')); // Move down to normal_entry.txt
tester.assert_tui_frame_contains("> normal_entry.txt");
// Select normal_entry.txt
tester.send(ENTER);
// Wait for selection to complete and exit
PtyTester::assert_exit_ok(&mut child, DEFAULT_DELAY);
}
// Verify frecency file was recreated with valid JSON
let frecency_content = fs::read_to_string(&frecency_file)
.expect("Should be able to read frecency file after recovery");
assert!(
frecency_content.starts_with('[') && frecency_content.ends_with(']'),
"Frecency file should be valid JSON array after recovery. Content:\n{}",
frecency_content
);
assert!(
frecency_content.contains("normal_entry.txt"),
"Frecency file should contain new entry after recovery. Content:\n{}",
frecency_content
);
// Second session: verify frecency is working normally after recovery
{
let mut tester = PtyTester::new();
let cmd = tv_frecency_with_data_dir(
data_dir,
&[
"--source-command",
"printf 'recovery_test.txt\nnormal_entry.txt'", // Reversed order
],
);
let mut child = tester.spawn_command_tui(cmd);
// Wait for UI to load and verify normal_entry.txt appears first due to frecency
tester.assert_tui_frame_contains("> normal_entry.txt");
tester.send(&ctrl('n')); // Move down to recovery_test.txt
tester.assert_tui_frame_contains("> recovery_test.txt");
// Exit cleanly
tester.send(&ctrl('c'));
PtyTester::assert_exit_ok(&mut child, DEFAULT_DELAY);
}
}
/// Tests frecency without the --frecency flag to ensure it's properly disabled.
#[test]
fn test_frecency_disabled_behavior() {
let temp_dir = create_temp_data_dir_with_empty_frecency();
let data_dir = temp_dir.path().to_str().unwrap();
// Create test data
let test_entries = ["first.txt", "second.txt", "third.txt"];
// First session: select third.txt WITHOUT --frecency flag
{
let mut tester = PtyTester::new();
let mut cmd = tv_local_config_and_cable_with_args(&[
"--source-command",
&format!("printf '{}'", test_entries.join("\n")),
]);
cmd.env("TELEVISION_DATA", data_dir);
// Note: NO --frecency flag
let mut child = tester.spawn_command_tui(cmd);
// Wait for UI to load and verify initial order
tester.assert_tui_frame_contains("> first.txt");
tester.send(&ctrl('n')); // Move down to second.txt
tester.assert_tui_frame_contains("> second.txt");
tester.send(&ctrl('n')); // Move down to third.txt
tester.assert_tui_frame_contains("> third.txt");
// Select third.txt
tester.send(ENTER);
// Wait for selection to complete and exit
PtyTester::assert_exit_ok(&mut child, DEFAULT_DELAY);
}
// Verify selected entry was not added to frecency data
let frecency_file = format!("{}/frecency.json", data_dir);
if std::path::Path::new(&frecency_file).exists() {
let frecency_content = fs::read_to_string(&frecency_file)
.unwrap_or_else(|_| "[]".to_string());
assert!(
!frecency_content.contains("third.txt"),
"Frecency file should not contain selected entry when --frecency flag is not used. Content:\n{}",
frecency_content
);
}
// Second session: verify order remains unchanged (no frecency boost)
{
let mut tester = PtyTester::new();
let mut cmd = tv_local_config_and_cable_with_args(&[
"--source-command",
&format!("printf '{}'", test_entries.join("\n")),
]);
cmd.env("TELEVISION_DATA", data_dir);
// Note: Still NO --frecency flag
let mut child = tester.spawn_command_tui(cmd);
// Wait for UI to load and verify original order maintained
tester.assert_tui_frame_contains("> first.txt");
tester.send(&ctrl('n')); // Move down to second.txt
tester.assert_tui_frame_contains("> second.txt");
tester.send(&ctrl('n')); // Move down to third.txt
tester.assert_tui_frame_contains("> third.txt");
// Exit cleanly
tester.send(&ctrl('c'));
PtyTester::assert_exit_ok(&mut child, DEFAULT_DELAY);
}
}

View File

@ -12,12 +12,14 @@
//! - UI customization and layout
//! - UI behavioral integration
//! - Error handling and validation
//! - Frecency scoring and ranking
#[path = "../common/mod.rs"]
mod common;
pub mod cli_config;
pub mod cli_errors;
pub mod cli_frecency;
pub mod cli_input;
pub mod cli_modes;
pub mod cli_monitoring;