feat: add global/channel input history (#573)

sometimes I want to tweak the previous search, or just run it again.
this solves that problem

---------

Co-authored-by: alexandre pasmantier <alex.pasmant@gmail.com>
This commit is contained in:
LM 2025-07-01 12:49:01 +02:00 committed by GitHub
parent e6008350d0
commit 45139457a1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 906 additions and 2 deletions

View File

@ -18,6 +18,18 @@
tick_rate = 50
default_channel = "files"
# History settings
# ---------------
# Maximum number of entries to keep in the global history (default: 100)
# The history tracks search queries across all channels and sessions
# Set to 0 to disable history functionality entirely
history_size = 200
# Whether to use global history (default: false)
# When true: history navigation shows entries from all channels
# When false: history navigation is scoped to the current channel
global_history = false
[ui]
# Whether to use nerd font icons in the UI
# This option requires a font patched with Nerd Font in order to properly
@ -123,6 +135,12 @@ select_prev_entry = ["up", "ctrl-p", "ctrl-k"]
#select_next_page = "pagedown"
#select_prev_page = "pageup"
# History navigation
# -----------------
# Navigate through search query history
select_prev_history = "ctrl-up"
select_next_history = "ctrl-down"
# Multi-selection
# --------------
# Add entry to selection and move to the next entry

View File

@ -435,6 +435,17 @@ Television's options are organized by functionality. Each option behaves differe
- **Default**: `~/.config/tv/cable/` (Linux/macOS) or `%APPDATA%\tv\cable\` (Windows)
- **Use Case**: Custom channel collections or shared team channels
### 📚 History Options
#### `--global-history`
**Purpose**: Enables global history for the current session
- **Both Modes**: Same behavior
- **Default**: Channel-specific history (scoped to current channel)
- **Use Case**: Cross-channel workflow when you want to see all recent searches
- **Example**: `tv files --global-history`
### 🔧 Special Mode Options
#### `--autocomplete-prompt <STRING>`

View File

@ -4,7 +4,7 @@
.SH NAME
television \- A 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\-\-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\-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\-\-preview\-header\fR] [\fB\-\-preview\-footer\fR] [\fB\-\-source\-command\fR] [\fB\-\-source\-display\fR] [\fB\-\-source\-output\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\-\-ui\-scale\fR] [\fB\-\-preview\-size\fR] [\fB\-\-config\-file\fR] [\fB\-\-cable\-dir\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\-\-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\-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\-\-preview\-header\fR] [\fB\-\-preview\-footer\fR] [\fB\-\-source\-command\fR] [\fB\-\-source\-display\fR] [\fB\-\-source\-output\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\-\-ui\-scale\fR] [\fB\-\-preview\-size\fR] [\fB\-\-config\-file\fR] [\fB\-\-cable\-dir\fR] [\fB\-\-global\-history\-mode\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR] [\fICHANNEL\fR] [\fIPATH\fR] [\fIsubcommands\fR]
.SH DESCRIPTION
A cross\-platform, fast and extensible general purpose fuzzy finder TUI.
.SH OPTIONS
@ -286,6 +286,14 @@ Provide a custom cable directory to use.
This flag works identically in both channel mode and ad\-hoc mode.
.TP
\fB\-\-global\-history\fR
Enable global history for the current session.
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\-h\fR, \fB\-\-help\fR
Print help (see a summary with \*(Aq\-h\*(Aq)
.TP

View File

@ -109,4 +109,8 @@ pub enum Action {
/// Timer action for watch mode to trigger periodic reloads.
#[serde(skip)]
WatchTimer,
/// Navigate to the previous entry in the history.
SelectPrevHistory,
/// Navigate to the next entry in the history.
SelectNextHistory,
}

View File

@ -4,6 +4,7 @@ use crate::{
channels::{entry::Entry, prototypes::ChannelPrototype},
config::{Config, DEFAULT_PREVIEW_SIZE, default_tick_rate},
event::{Event, EventLoop, Key},
history::History,
keymap::Keymap,
render::{RenderingTask, UiState, render},
television::{Mode, Television},
@ -12,7 +13,7 @@ use anyhow::Result;
use crossterm::event::MouseEventKind;
use rustc_hash::FxHashSet;
use tokio::sync::mpsc;
use tracing::{debug, trace};
use tracing::{debug, error, trace};
#[allow(clippy::struct_excessive_bools)]
pub struct AppOptions {
@ -119,6 +120,8 @@ pub struct App {
options: AppOptions,
/// Watch timer task handle for periodic reloading
watch_timer_task: Option<tokio::task::JoinHandle<()>>,
/// Global history for selected entries
history: History,
}
/// The outcome of an action.
@ -186,6 +189,16 @@ impl App {
let keymap = Keymap::from(&television.config.keybindings);
debug!("{:?}", keymap);
let mut history = History::new(
television.config.application.history_size,
&television.channel_prototype.metadata.name,
television.config.application.global_history,
&television.config.application.data_dir.clone(),
);
if let Err(e) = history.init() {
error!("Failed to initialize history: {}", e);
}
let mut app = Self {
keymap,
television,
@ -202,6 +215,7 @@ impl App {
render_task: None,
options,
watch_timer_task: None,
history,
};
// populate keymap by going through all cable channels and adding their shortcuts if remote
@ -278,6 +292,23 @@ impl App {
debug!("Updated keymap (with shortcuts): {:?}", self.keymap);
}
/// Updates the history configuration to match the current channel.
fn update_history(&mut self) {
let channel_prototype = &self.television.channel_prototype;
// Determine the effective global_history (channel overrides global config)
let global_mode = channel_prototype
.history
.global_mode
.unwrap_or(self.television.config.application.global_history);
// Update existing history with new channel context
self.history.update_channel_context(
&channel_prototype.metadata.name,
global_mode,
);
}
/// Run the application main loop.
///
/// This function will start the event loop and the rendering loop and handle
@ -378,6 +409,11 @@ impl App {
self.event_abort_tx.send(())?;
}
// persist search history
if let Err(e) = self.history.save_to_file() {
error!("Failed to persist history: {}", e);
}
// wait for the rendering task to finish
if let Some(rendering_task) = self.render_task.take() {
rendering_task.await??;
@ -529,6 +565,13 @@ impl App {
if let Some(entries) =
self.television.get_selected_entries()
{
// Add current query to history
let query =
self.television.current_pattern.clone();
self.history.add_entry(
query,
self.television.current_channel(),
)?;
return Ok(ActionOutcome::Entries(entries));
}
@ -552,6 +595,23 @@ impl App {
self.television.update_ui_state(ui_state);
}
}
Action::SelectPrevHistory => {
if let Some(history_entry) =
self.history.get_previous_entry()
{
self.television.set_pattern(&history_entry.query);
}
}
Action::SelectNextHistory => {
if let Some(history_entry) =
self.history.get_next_entry()
{
self.television.set_pattern(&history_entry.query);
} else {
// At the end of history, clear the input
self.television.set_pattern("");
}
}
_ => {}
}
// Check if we're switching from remote control to channel mode
@ -570,10 +630,12 @@ impl App {
&& self.television.mode == Mode::Channel
{
self.update_keymap();
self.update_history();
self.restart_watch_timer();
} else if matches!(action, Action::SwitchToChannel(_)) {
// Channel changed via shortcut, refresh keymap and watch timer
self.update_keymap();
self.update_history();
self.restart_watch_timer();
}
}

View File

@ -168,6 +168,13 @@ impl ChannelKeyBindings {
}
}
#[derive(Default, Debug, Clone, serde::Deserialize, serde::Serialize)]
pub struct HistoryConfig {
/// Whether to use global history 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,
@ -182,6 +189,8 @@ pub struct ChannelPrototype {
/// Watch interval in seconds for automatic reloading (0 = disabled)
#[serde(default)]
pub watch: f64,
#[serde(default)]
pub history: HistoryConfig,
// actions: Vec<Action>,
}
@ -210,6 +219,7 @@ impl ChannelPrototype {
ui: None,
keybindings: None,
watch: 0.0,
history: HistoryConfig::default(),
}
}
@ -239,6 +249,7 @@ impl ChannelPrototype {
ui: None,
keybindings: None,
watch: 0.0,
history: HistoryConfig::default(),
}
}

View File

@ -414,6 +414,15 @@ pub struct Cli {
#[arg(long, value_name = "PATH", verbatim_doc_comment, value_parser = validate_directory_path)]
pub cable_dir: Option<String>,
/// Use global history instead of channel-specific history.
///
/// 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.
#[arg(long, verbatim_doc_comment)]
pub global_history: bool,
#[command(subcommand)]
pub command: Option<Command>,
}

View File

@ -97,6 +97,9 @@ pub struct PostProcessedCli {
pub tick_rate: Option<f64>,
pub watch_interval: Option<f64>,
// History configuration
pub global_history: bool,
// Configuration sources
pub config_file: Option<PathBuf>,
pub cable_dir: Option<PathBuf>,
@ -161,6 +164,9 @@ impl Default for PostProcessedCli {
tick_rate: None,
watch_interval: None,
// History configuration
global_history: false,
// Configuration sources
config_file: None,
cable_dir: None,
@ -338,6 +344,9 @@ pub fn post_process(cli: Cli, readable_stdin: bool) -> PostProcessedCli {
tick_rate: cli.tick_rate,
watch_interval: cli.watch,
// History configuration
global_history: cli.global_history,
// Configuration sources
config_file: cli.config_file.map(|p| expand_tilde(&p)),
cable_dir: cli.cable_dir.map(|p| expand_tilde(&p)),

View File

@ -1,6 +1,7 @@
use crate::{
cable::CABLE_DIR_NAME,
channels::prototypes::{DEFAULT_PROTOTYPE_NAME, UiSpec},
history::DEFAULT_HISTORY_SIZE,
};
use anyhow::{Context, Result};
use directories::ProjectDirs;
@ -40,17 +41,33 @@ pub struct AppConfig {
/// The default channel to use when no channel is specified
#[serde(default = "default_channel")]
pub default_channel: String,
/// Maximum number of entries to keep in the global history
#[serde(default = "default_history_size")]
pub history_size: usize,
/// Whether to use global history (all channels) or channel-specific history (default)
#[serde(default = "default_global_history")]
pub global_history: bool,
}
fn default_channel() -> String {
DEFAULT_PROTOTYPE_NAME.to_string()
}
fn default_history_size() -> usize {
DEFAULT_HISTORY_SIZE
}
fn default_global_history() -> bool {
false
}
impl Hash for AppConfig {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.data_dir.hash(state);
self.config_dir.hash(state);
self.tick_rate.to_bits().hash(state);
self.history_size.hash(state);
self.global_history.hash(state);
}
}

737
television/history.rs Normal file
View File

@ -0,0 +1,737 @@
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::{
path::{Path, PathBuf},
time::{SystemTime, UNIX_EPOCH},
};
use tracing::debug;
const HISTORY_FILE_NAME: &str = "history.json";
pub const DEFAULT_HISTORY_SIZE: usize = 200;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HistoryEntry {
/// The search query/pattern that was typed
pub query: String,
/// The channel that the entry belongs to
pub channel: String,
/// The timestamp of the entry
pub timestamp: u64,
}
impl PartialEq for HistoryEntry {
fn eq(&self, other: &Self) -> bool {
self.query == other.query && self.channel == other.channel
}
}
impl HistoryEntry {
pub fn new(entry: String, channel: String) -> Self {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
Self {
query: entry,
channel,
timestamp,
}
}
}
#[derive(Debug, Clone)]
pub struct History {
entries: Vec<HistoryEntry>,
current_index: Option<usize>,
max_size: usize,
file_path: PathBuf,
current_channel: String,
global_mode: bool,
}
impl History {
pub fn new(
max_size: usize,
channel_name: &str,
global_mode: bool,
data_dir: &Path,
) -> Self {
let file_path = data_dir.join(HISTORY_FILE_NAME);
Self {
entries: Vec::with_capacity(max_size),
current_index: None,
max_size,
file_path,
current_channel: channel_name.to_string(),
global_mode,
}
}
/// Initialize the history by loading previously persisted entries from disk.
pub fn init(&mut self) -> Result<()> {
// if max_size is 0, history is disabled
if self.max_size > 0 {
self.load_from_file()?;
}
Ok(())
}
/// Add a new history entry, if it's not a duplicate.
pub fn add_entry(&mut self, query: String, channel: String) -> Result<()> {
// Don't add empty queries
if query.trim().is_empty() {
return Ok(());
}
// Don't add duplicate consecutive queries
if let Some(last_entry) = self.entries.last() {
if last_entry.query == query && last_entry.channel == channel {
return Ok(());
}
}
// Trim history if it's going to exceed `max_size`
if self.entries.len() + 1 > self.max_size {
self.entries.drain(0..=self.entries.len() - self.max_size);
}
let history_entry = HistoryEntry::new(query, channel);
self.entries.push(history_entry);
// Reset current index when adding new entry
self.current_index = None;
Ok(())
}
/// Get the previous history entry based on the configured mode.
pub fn get_previous_entry(&mut self) -> Option<&HistoryEntry> {
let channel_filter =
(!self.global_mode).then_some(self.current_channel.as_str());
let search_end = match self.current_index {
None => self.entries.len(),
Some(0) => {
return self.entries.first().filter(|entry| {
channel_filter.is_none_or(|ch| entry.channel == ch)
});
}
Some(i) => i,
};
self.entries
.get(..search_end)?
.iter()
.enumerate()
.rev()
.find(|(_, entry)| {
channel_filter.is_none_or(|ch| entry.channel == ch)
})
.map(|(idx, entry)| {
self.current_index = Some(idx);
entry
})
}
/// Get the next history entry based on the configured mode.
pub fn get_next_entry(&mut self) -> Option<&HistoryEntry> {
let channel_filter =
(!self.global_mode).then_some(self.current_channel.as_str());
let search_start = self.current_index? + 1;
self.entries
.get(search_start..)?
.iter()
.enumerate()
.find(|(_, entry)| {
channel_filter.is_none_or(|ch| entry.channel == ch)
})
.map(|(offset, entry)| {
self.current_index = Some(search_start + offset);
entry
})
.or_else(|| {
self.current_index = None; // Reset navigation at end
None
})
}
fn load_from_file(&mut self) -> Result<()> {
if !self.file_path.exists() {
debug!("History 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!("History file is empty: {}", self.file_path.display());
return Ok(());
}
let mut loaded_entries: Vec<HistoryEntry> =
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.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!("History 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()
}
/// Get all entries in the history.
pub fn get_entries(&self) -> &[HistoryEntry] {
&self.entries
}
/// Update the current channel context for this history 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;
// Reset navigation state when switching channels
self.current_index = None;
}
}
#[cfg(test)]
mod tests {
//! Tests for History navigation helpers (channel vs. global mode).
use crate::history::{History, HistoryEntry};
use serde_json;
use std::{fs, path::Path};
use tempfile::tempdir;
/// Helper to create a history.json file with the provided entries inside a temp data dir.
fn setup_history_file(entries: &[HistoryEntry]) -> tempfile::TempDir {
let dir = tempdir().expect("failed to create tempdir");
let json = serde_json::to_string_pretty(entries).unwrap();
fs::write(dir.path().join("history.json"), json).unwrap();
dir
}
fn make_entries() -> Vec<HistoryEntry> {
vec![
HistoryEntry::new("file1".into(), "files".into()),
HistoryEntry::new("dir1".into(), "dirs".into()),
HistoryEntry::new("file2".into(), "files".into()),
HistoryEntry::new("dir2".into(), "dirs".into()),
HistoryEntry::new("file3".into(), "files".into()),
]
}
/// Return a Vec of entry strings in newest→oldest order.
#[allow(dead_code)]
fn dump_entries(hist: &History) -> Vec<String> {
let mut h = hist.clone();
let mut acc = Vec::new();
let max = h.len();
for _ in 0..max {
match h.get_previous_entry() {
Some(e) => acc.push(e.query.clone()),
None => break,
}
}
acc
}
#[allow(dead_code)]
fn print_entries(hist: &History) {
println!("{:?}", dump_entries(hist));
}
/// Read all entries currently stored in history.json for a data dir
fn entries_in_file(dir: &Path) -> Vec<String> {
let raw = fs::read_to_string(dir.join("history.json")).unwrap();
let vec: Vec<HistoryEntry> = serde_json::from_str(&raw).unwrap();
vec.into_iter().map(|e| e.query).collect()
}
#[allow(dead_code)]
fn assert_entries_in_file(dir: &Path, expected: &[&str]) {
let mut got = entries_in_file(dir);
got.sort();
let mut exp: Vec<String> =
expected.iter().map(|&s| s.to_string()).collect();
exp.sort();
assert_eq!(got, exp);
}
#[allow(dead_code)]
fn assert_entries(hist: &History, expected: &[&str]) {
let mut got: Vec<String> =
hist.get_entries().iter().map(|e| e.query.clone()).collect();
got.sort();
let mut exp: Vec<String> =
expected.iter().map(|&s| s.to_string()).collect();
exp.sort();
assert_eq!(got, exp);
}
/// Prev/next navigation scoped to current channel.
#[test]
fn prev_next_channel_mode() {
let dir = setup_history_file(&make_entries());
let mut hist = History::new(10, "files", false, dir.path());
hist.init().unwrap();
// channel mode prev navigation (should skip non-matching channels)
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"file3".to_string())
);
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"file2".to_string())
);
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"file1".to_string())
);
// further calls stay at oldest matching
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"file1".to_string())
);
// Next navigation forward in channel scope
assert_eq!(
hist.get_next_entry().map(|e| &e.query),
Some(&"file2".to_string())
);
assert_eq!(
hist.get_next_entry().map(|e| &e.query),
Some(&"file3".to_string())
);
// After reaching the last matching entry, further next should reset and return None
assert_eq!(hist.get_next_entry(), None);
}
/// Prev/next navigation in global mode.
#[test]
fn prev_next_global_mode() {
let dir = setup_history_file(&make_entries());
let mut hist = History::new(10, "files", true, dir.path());
hist.init().unwrap();
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"file3".to_string())
);
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"dir2".to_string())
);
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"file2".to_string())
);
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"dir1".to_string())
);
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"file1".to_string())
);
// further calls stay at oldest matching
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"file1".to_string())
);
// Next navigation forward in channel scope
assert_eq!(
hist.get_next_entry().map(|e| &e.query),
Some(&"dir1".to_string())
);
assert_eq!(
hist.get_next_entry().map(|e| &e.query),
Some(&"file2".to_string())
);
assert_eq!(
hist.get_next_entry().map(|e| &e.query),
Some(&"dir2".to_string())
);
assert_eq!(
hist.get_next_entry().map(|e| &e.query),
Some(&"file3".to_string())
);
// After reaching the last matching entry, further next should reset and return None
assert_eq!(hist.get_next_entry(), None);
}
/// Channel-scoped navigation when the history file contains no entries for the
/// current channel navigation should immediately return `None` both ways.
#[test]
fn channel_mode_no_matching_entries() {
let only_b = vec![HistoryEntry::new("dir1".into(), "dirs".into())];
let dir = setup_history_file(&only_b);
let mut hist = History::new(10, "files", false, dir.path());
hist.init().unwrap();
assert!(hist.get_previous_entry().is_none());
assert!(hist.get_next_entry().is_none());
}
/// Global mode navigation when there is only a single entry that does not
/// belong to the current channel that entry should still be accessible.
#[test]
fn global_mode_single_nonmatching_entry() {
let only_b = vec![HistoryEntry::new("dir1".into(), "dirs".into())];
let dir = setup_history_file(&only_b);
let mut hist = History::new(10, "files", true, dir.path());
hist.init().unwrap();
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"dir1".to_string())
);
}
/// Ensure `add_entry` respects deduplication and size trimming.
/// Adding entries should skip consecutive duplicates and trim to max size.
#[test]
fn add_entry_dedup_and_trim() {
let dir = setup_history_file(&[]);
let mut hist = History::new(3, "files", false, dir.path());
// add first two unique entries
hist.add_entry("file1".into(), "files".into()).unwrap();
assert_entries(&hist, &["file1"]);
hist.add_entry("file2".into(), "files".into()).unwrap();
assert_entries(&hist, &["file1", "file2"]);
// consecutive duplicates should be ignored
hist.add_entry("file2".into(), "files".into()).unwrap();
assert_entries(&hist, &["file1", "file2"]);
assert_eq!(hist.len(), 2);
// let's add a third unique entry
hist.add_entry("file3".into(), "files".into()).unwrap();
assert_entries(&hist, &["file1", "file2", "file3"]);
assert_eq!(hist.len(), 3);
// let's add a fourth unique entry, we should still have 3 entries
hist.add_entry("file4".into(), "files".into()).unwrap();
assert_entries(&hist, &["file2", "file3", "file4"]);
assert_eq!(hist.len(), 3);
// non-consecutive duplicate counts as new
hist.add_entry("dir1".into(), "dirs".into()).unwrap();
assert_entries(&hist, &["file3", "file4", "dir1"]);
assert_eq!(hist.len(), 3);
// In channel mode (files) the newest matching is file4
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"file4".to_string())
);
// Persist so we can load later
hist.save_to_file().unwrap();
// Switch to global view to verify "dir1" exists
// let's init the history with channel only mode for dirs
let mut hist = History::new(3, "dirs", false, dir.path());
hist.init().unwrap();
assert_entries(&hist, &["file3", "file4", "dir1"]);
assert_eq!(hist.len(), 3);
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"dir1".to_string())
);
}
/// Global mode: initializing with smaller `max_size` trims older entries.
#[test]
fn init_trim_global_mode() {
let dir = setup_history_file(&make_entries());
// max size 3, global mode
let mut hist = History::new(3, "files", true, dir.path());
hist.init().unwrap();
// Should keep only the 3 newest entries (file2, dir2, file3)
assert_eq!(hist.len(), 3);
assert_eq!(dump_entries(&hist), vec!["file3", "dir2", "file2"]);
}
/// Test loading from a non-existent history file.
#[test]
fn init_from_nonexistent_file() {
let dir = tempdir().expect("failed to create tempdir");
let mut hist = History::new(10, "files", false, dir.path());
// Should succeed and have empty entries
hist.init().unwrap();
assert_eq!(hist.len(), 0);
assert!(hist.is_empty());
// Navigation should return None
assert!(hist.get_previous_entry().is_none());
assert!(hist.get_next_entry().is_none());
}
/// Test loading from an empty history file.
#[test]
fn init_from_empty_file() {
let dir = tempdir().expect("failed to create tempdir");
fs::write(dir.path().join("history.json"), "").unwrap();
let mut hist = History::new(10, "files", false, dir.path());
hist.init().unwrap();
assert_eq!(hist.len(), 0);
assert!(hist.is_empty());
}
/// Test that empty queries are ignored.
#[test]
fn add_entry_ignores_empty_queries() {
let dir = tempdir().expect("failed to create tempdir");
let mut hist = History::new(10, "files", false, dir.path());
// Try to add empty and whitespace-only queries
hist.add_entry(String::new(), "files".into()).unwrap();
hist.add_entry(" ".into(), "files".into()).unwrap();
hist.add_entry("\t\n".into(), "files".into()).unwrap();
assert_eq!(hist.len(), 0);
assert!(hist.is_empty());
// Add a real entry to confirm it works
hist.add_entry("real_entry".into(), "files".into()).unwrap();
assert_eq!(hist.len(), 1);
assert_entries(&hist, &["real_entry"]);
}
/// Test navigation on completely empty history.
#[test]
fn navigation_empty_history() {
let dir = tempdir().expect("failed to create tempdir");
let mut hist = History::new(10, "files", false, dir.path());
// Both modes should return None on empty history
assert!(hist.get_previous_entry().is_none());
assert!(hist.get_next_entry().is_none());
// Global mode should also return None
let mut hist_global = History::new(10, "files", true, dir.path());
assert!(hist_global.get_previous_entry().is_none());
assert!(hist_global.get_next_entry().is_none());
}
/// Test navigation state after adding entries.
#[test]
fn navigation_after_adding_entries() {
let dir = tempdir().expect("failed to create tempdir");
let mut hist = History::new(10, "files", false, dir.path());
// Add first file
hist.add_entry("file1".into(), "files".into()).unwrap();
// Should be able to navigate to it
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"file1".to_string())
);
// Add another file - should reset navigation
hist.add_entry("file2".into(), "files".into()).unwrap();
// Should now get the newest file first
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"file2".to_string())
);
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"file1".to_string())
);
}
/// Test non-consecutive duplicates are properly handled.
#[test]
fn non_consecutive_duplicates() {
let dir = tempdir().expect("failed to create tempdir");
let mut hist = History::new(10, "files", false, dir.path());
hist.add_entry("file1".into(), "files".into()).unwrap();
hist.add_entry("file2".into(), "files".into()).unwrap();
hist.add_entry("file1".into(), "files".into()).unwrap(); // Non-consecutive duplicate
assert_eq!(hist.len(), 3);
assert_entries(&hist, &["file1", "file2", "file1"]);
// Both instances should be navigable
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"file1".to_string())
);
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"file2".to_string())
);
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"file1".to_string())
);
}
/// Test cross-channel duplicates.
#[test]
fn cross_channel_duplicates() {
let dir = tempdir().expect("failed to create tempdir");
let mut hist = History::new(10, "files", false, dir.path());
hist.add_entry("same_name".into(), "files".into()).unwrap();
hist.add_entry("same_name".into(), "dirs".into()).unwrap();
hist.add_entry("same_name".into(), "files".into()).unwrap();
assert_eq!(hist.len(), 3);
assert_entries(&hist, &["same_name", "same_name", "same_name"]);
// In channel mode, should only see the files entries
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"same_name".to_string())
);
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"same_name".to_string())
);
// Persist so we can load later
hist.save_to_file().unwrap();
// In global mode, should see all three
let mut hist_global = History::new(10, "files", true, dir.path());
hist_global.init().unwrap();
assert_eq!(
hist_global.get_previous_entry().map(|e| &e.query),
Some(&"same_name".to_string())
);
assert_eq!(
hist_global.get_previous_entry().map(|e| &e.query),
Some(&"same_name".to_string())
);
assert_eq!(
hist_global.get_previous_entry().map(|e| &e.query),
Some(&"same_name".to_string())
);
}
/// Test navigation state preservation during mixed operations.
#[test]
fn navigation_state_preservation() {
let dir = setup_history_file(&make_entries());
let mut hist = History::new(10, "files", false, dir.path());
hist.init().unwrap();
// Start navigation
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"file3".to_string())
);
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"file2".to_string())
);
// Add a new entry - should reset navigation state
hist.add_entry("new_file".into(), "files".into()).unwrap();
// Should start from newest again
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"new_file".to_string())
);
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"file3".to_string())
);
}
/// Test mixed navigation patterns.
#[test]
fn mixed_navigation_patterns() {
let dir = setup_history_file(&make_entries());
let mut hist = History::new(10, "files", false, dir.path());
hist.init().unwrap();
// Go back three times
hist.get_previous_entry();
hist.get_previous_entry();
hist.get_previous_entry();
// Go forward once
assert_eq!(
hist.get_next_entry().map(|e| &e.query),
Some(&"file2".to_string())
);
// Go back again
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"file1".to_string())
);
// Go forward to end and beyond
hist.get_next_entry();
hist.get_next_entry();
assert!(hist.get_next_entry().is_none());
// After reset, should be able to start navigation again
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"file3".to_string())
);
}
/// Test `get_next` without previous navigation.
#[test]
fn next_without_previous() {
let dir = setup_history_file(&make_entries());
let mut hist = History::new(10, "files", false, dir.path());
hist.init().unwrap();
// Calling get_next without any previous navigation should return None
assert!(hist.get_next_entry().is_none());
}
}

View File

@ -9,6 +9,7 @@ pub mod errors;
pub mod event;
pub mod features;
pub mod gh;
pub mod history;
pub mod input;
pub mod keymap;
pub mod logging;

View File

@ -145,6 +145,9 @@ fn apply_cli_overrides(args: &PostProcessedCli, config: &mut Config) {
if let Some(tick_rate) = args.tick_rate {
config.application.tick_rate = tick_rate;
}
if args.global_history {
config.application.global_history = true;
}
// Handle preview panel flags
if args.no_preview {
config.ui.features.disable(FeatureFlags::PreviewPanel);

View File

@ -463,6 +463,20 @@ impl Television {
}
}
}
/// Update the current pattern and input field (used for history navigation)
pub fn set_pattern(&mut self, pattern: &str) {
self.current_pattern = pattern.to_string();
if self.mode == Mode::Channel {
self.results_picker.input = self
.results_picker
.input
.clone()
.with_value(pattern.to_string());
self.find(pattern);
self.reset_picker_selection();
}
}
}
/// Always render the first N ticks.