mirror of
https://github.com/alexpasmantier/television.git
synced 2025-07-29 06:11:37 +00:00
Merge 36210026f8bc58e8b22cceaf22b8c034135389d3 into 83f29f7418a7adb6f5ca9ee5ac057ef38728fa28
This commit is contained in:
commit
58772af0bc
@ -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
|
||||
|
76
man/tv.1
76
man/tv.1
@ -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.
|
||||
|
||||
|
@ -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>,
|
||||
|
@ -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, ¤t_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, ¤t_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");
|
||||
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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.
|
||||
|
@ -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
328
television/frecency.rs
Normal 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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -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
577
tests/cli/cli_frecency.rs
Normal 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);
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
Loading…
x
Reference in New Issue
Block a user