mirror of
https://github.com/alexpasmantier/television.git
synced 2025-07-29 14:21:43 +00:00
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:
parent
e6008350d0
commit
45139457a1
@ -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
|
||||
|
11
docs/cli.md
11
docs/cli.md
@ -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>`
|
||||
|
10
man/tv.1
10
man/tv.1
@ -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
|
||||
|
@ -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,
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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>,
|
||||
}
|
||||
|
@ -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)),
|
||||
|
@ -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
737
television/history.rs
Normal 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());
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -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);
|
||||
|
@ -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.
|
||||
|
Loading…
x
Reference in New Issue
Block a user