use super::{config, highlight::PreviewCache, ui::ui};
use crate::{
error::any_to_string,
file::toml,
model::{
command::{self},
histories::{self},
js_package_manager::js_package_manager_main as js,
just::just_main::Just,
make::make_main::Make,
runner::{self, Runner},
runner_type,
task::task_main::Task,
},
};
use anyhow::{Result, anyhow, bail};
use arboard::Clipboard;
use crossterm::{
event::{DisableMouseCapture, EnableMouseCapture, KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use futures::FutureExt;
use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2};
use ratatui::{
Terminal,
backend::{Backend, CrosstermBackend},
widgets::ListState,
};
use std::{
collections::HashMap,
env,
io::{self, Stderr},
panic::AssertUnwindSafe,
path::PathBuf,
sync::{Arc, Mutex},
time::Duration,
};
use tokio::task;
use tui_textarea::TextArea;
use update_informer::{Check, registry};
#[derive(Debug)]
pub enum AppState<'a> {
SelectingCommand(Box<SelectingCommandState<'a>>),
SelectedCommand(SelectedCommandState),
Quitting(Result<()>),
}
impl PartialEq for AppState<'_> {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(AppState::SelectingCommand(a), AppState::SelectingCommand(b)) => a == b,
(AppState::SelectedCommand(a), AppState::SelectedCommand(b)) => a == b,
(AppState::Quitting(a), AppState::Quitting(b)) => match (a, b) {
(Ok(()), Ok(())) => true,
(Err(a), Err(b)) => a.to_string() == b.to_string(),
_ => false,
},
_ => false,
}
}
}
#[derive(PartialEq, Debug)]
pub struct Model<'a> {
pub app_state: AppState<'a>,
}
impl Model<'_> {
pub fn new(config: config::Config) -> Result<Self> {
match SelectingCommandState::new(config) {
Ok(s) => Ok(Model {
app_state: AppState::SelectingCommand(Box::new(s)),
}),
Err(e) => Err(e),
}
}
fn handle_key_input(&self, key: KeyEvent) -> Option<Message> {
if let AppState::SelectingCommand(s) = &self.app_state {
let is_ctrl_pressed = key.modifiers.contains(KeyModifiers::CONTROL);
if let Some(additional_arguments_popup_state) = &s.additional_arguments_popup_state {
match key.code {
KeyCode::Esc => Some(Message::CloseAdditionalArgumentsWindow),
KeyCode::Tab => None, KeyCode::Enter => {
Some(Message::ExecuteCommand(additional_arguments_popup_state.append_arguments()))
}
_ => Some(Message::AdditionalArgumentsKeyInput(key)),
}
} else {
match s.current_pane {
CurrentPane::Main => match (key.code, is_ctrl_pressed) {
(KeyCode::Tab, _) => Some(Message::MoveToNextPane),
(KeyCode::Esc, _) | (KeyCode::Char('c'), true) => Some(Message::Quit),
(KeyCode::Down, _) | (KeyCode::Char('n'), true) => Some(Message::NextCommand),
(KeyCode::Up, _) | (KeyCode::Char('p'), true) => Some(Message::PreviousCommand),
(KeyCode::Char('o'), true) => Some(Message::OpenAdditionalArgumentsWindow),
(KeyCode::Char('y'), true) => Some(Message::CopyCommandToClipboard),
(KeyCode::Enter, _) => match s.get_selected_command() {
Some(c) => Some(Message::ExecuteCommand(c)),
None => Some(Message::NoCommandSelected),
},
_ => Some(Message::SearchTextAreaKeyInput(key)),
},
CurrentPane::History => match (key.code, is_ctrl_pressed) {
(KeyCode::Tab, _) => Some(Message::MoveToNextPane),
(KeyCode::Esc, _) | (KeyCode::Char('c'), true) | (KeyCode::Char('q'), _) => Some(Message::Quit),
(KeyCode::Down, _) | (KeyCode::Char('n'), true) => Some(Message::NextHistory),
(KeyCode::Up, _) | (KeyCode::Char('p'), true) => Some(Message::PreviousHistory),
(KeyCode::Char('o'), true) => Some(Message::OpenAdditionalArgumentsWindow),
(KeyCode::Char('y'), true) => Some(Message::CopyCommandToClipboard),
(KeyCode::Enter, _) | (KeyCode::Char(' '), _) => match s.get_selected_command() {
Some(c) => Some(Message::ExecuteCommand(c)),
None => Some(Message::NoCommandSelected),
},
_ => None,
},
}
}
} else {
None
}
}
fn get_histories(current_working_directory: PathBuf) -> Vec<histories::HistoryCommand> {
let histories = toml::Histories::into(toml::Histories::get_history());
for history in histories.histories {
if history.path != current_working_directory {
continue;
}
return history.commands;
}
vec![]
}
fn transition_to_selected_command_state(&mut self, runner: runner::Runner, command: command::CommandForExec) {
self.app_state = AppState::SelectedCommand(SelectedCommandState::new(runner, command));
}
fn transition_to_quitting_state(&mut self, quit_result: Result<()>) {
self.app_state = AppState::Quitting(quit_result);
}
fn should_quit(&self) -> Option<&Result<()>> {
match &self.app_state {
AppState::Quitting(quit_result) => Some(quit_result),
_ => None,
}
}
fn is_command_selected(&self) -> bool {
matches!(self.app_state, AppState::SelectedCommand(_))
}
fn command_to_execute(&self) -> Option<(runner::Runner, command::CommandForExec)> {
match &self.app_state {
AppState::SelectedCommand(command) => {
let command = command.clone();
Some((command.executor, command.command))
}
_ => None,
}
}
}
pub async fn main(config: config::Config) -> Result<()> {
enable_raw_mode()?;
let mut stderr = io::stderr();
execute!(stderr, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stderr);
let mut terminal = Terminal::new(backend)?;
let result = AssertUnwindSafe(async {
match Model::new(config) {
Ok(mut m) => {
match run(&mut terminal, &mut m).await {
Ok(command) => match command {
Some((runner, command)) => Ok(Some((runner, command))),
None => Ok(None), },
Err(e) => Err(e),
}
}
Err(e) => Err(e),
}
})
.catch_unwind()
.await;
shutdown_terminal(&mut terminal)?;
match result {
Ok(Ok(Some((runner, command)))) => {
runner.show_command(&command);
runner.execute(&command)
}
Ok(Ok(None)) => Ok(()), Ok(Err(e)) => Err(e), Err(e) => {
use colored::Colorize;
if let Some((location, message)) = crate::panic_info::get_panic_info() {
eprintln!("{}", format!("thread 'main' panicked at {}", location).red());
eprintln!("{}", message.red());
#[cfg(debug_assertions)]
{
eprintln!("\n{}", "Panic details have been appended to `debug_info.txt`".red());
}
}
Err(anyhow!(any_to_string::any_to_string(&*e))) }
}
}
const VERSION_KEY: &str = "version";
async fn run<'a, B: Backend>(
terminal: &mut Terminal<B>,
model: &'a mut Model<'a>,
) -> Result<Option<(runner::Runner, command::CommandForExec)>> {
let shared_version_hash_map = Arc::new(Mutex::new(HashMap::new()));
let cloned_hash_map = shared_version_hash_map.clone();
tokio::spawn(get_latest_version(cloned_hash_map));
loop {
if let AppState::SelectingCommand(s) = &mut model.app_state
&& s.latest_version.is_none()
&& let Some(new_version) = shared_version_hash_map.lock().unwrap().get(VERSION_KEY)
{
s.latest_version = Some(new_version.to_string());
}
if let AppState::SelectingCommand(s) = &model.app_state {
s.load_preview();
}
if let Err(e) = terminal.draw(|f| ui(f, model)) {
return Err(anyhow!(e));
}
let timeout = match &model.app_state {
AppState::SelectingCommand(s) if s.preview_cache.is_highlighting() => FRAME_POLL_TIMEOUT,
_ => IDLE_POLL_TIMEOUT,
};
match handle_event(model, timeout) {
Ok(message) => {
update(model, message);
match model.should_quit() {
Some(Ok(())) => break,
Some(Err(e)) => return Err(anyhow!("{e}")),
None => {}
}
if model.is_command_selected() {
break;
}
}
Err(_) => break,
}
}
Ok(model.command_to_execute())
}
fn shutdown_terminal(terminal: &mut Terminal<CrosstermBackend<Stderr>>) -> Result<()> {
if let Err(e) = disable_raw_mode() {
return Err(anyhow!(e));
}
execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
if let Err(e) = terminal.show_cursor() {
return Err(anyhow!(e));
}
Ok(())
}
const PKG_NAME: &str = "kyu08/fzf-make";
async fn get_latest_version(share_clone: Arc<Mutex<HashMap<String, String>>>) {
let current_version = env!("CARGO_PKG_VERSION").to_string();
let check_interval = Duration::from_secs(60 * 60); let informer = update_informer::new(registry::GitHub, PKG_NAME, current_version).interval(check_interval);
let version_result = task::spawn_blocking(|| informer.check_version().map_err(|e| e.to_string()))
.await
.unwrap();
if let Ok(Some(new_version)) = version_result {
let mut data = share_clone.lock().unwrap();
data.insert(VERSION_KEY.to_string(), new_version.to_string().clone());
};
}
enum Message {
SearchTextAreaKeyInput(KeyEvent),
ExecuteCommand(command::CommandForExec),
NextCommand,
PreviousCommand,
MoveToNextPane,
NextHistory,
PreviousHistory,
NoCommandSelected,
Quit,
OpenAdditionalArgumentsWindow,
CloseAdditionalArgumentsWindow,
AdditionalArgumentsKeyInput(KeyEvent),
CopyCommandToClipboard,
}
const IDLE_POLL_TIMEOUT: Duration = Duration::from_millis(500);
const FRAME_POLL_TIMEOUT: Duration = Duration::from_millis(16);
fn handle_event(model: &Model, timeout: Duration) -> io::Result<Option<Message>> {
if crossterm::event::poll(timeout)? {
match crossterm::event::read()? {
crossterm::event::Event::Key(key) if key.kind == KeyEventKind::Press => Ok(model.handle_key_input(key)),
_ => Ok(None),
}
} else {
Ok(None)
}
}
fn update(model: &mut Model, message: Option<Message>) {
if let AppState::SelectingCommand(ref mut s) = model.app_state {
match message {
Some(Message::SearchTextAreaKeyInput(key_event)) => s.handle_key_input(key_event),
Some(Message::ExecuteCommand(command)) => {
s.store_history(command.clone());
if let Some(r) = command.runner_type.to_runner(&s.runners) {
model.transition_to_selected_command_state(r, command);
}
}
Some(Message::NextCommand) => s.next_command(),
Some(Message::PreviousCommand) => s.previous_command(),
Some(Message::MoveToNextPane) => s.move_to_next_pane(),
Some(Message::NextHistory) => s.next_history(),
Some(Message::PreviousHistory) => s.previous_history(),
Some(Message::NoCommandSelected) => model.transition_to_quitting_state(Err(anyhow!("No command selected"))),
Some(Message::Quit) => model.transition_to_quitting_state(Ok(())),
Some(Message::OpenAdditionalArgumentsWindow) => s.open_additional_arguments_popup(),
Some(Message::CloseAdditionalArgumentsWindow) => s.close_additional_arguments_popup(),
Some(Message::AdditionalArgumentsKeyInput(key_event)) => s.handle_additional_arguments_key_input(key_event),
Some(Message::CopyCommandToClipboard) => s.copy_command_to_clipboard(),
None => {}
}
}
}
#[derive(Debug)]
pub struct SelectingCommandState<'a> {
pub current_dir: PathBuf,
pub current_pane: CurrentPane,
pub runners: Vec<runner::Runner>,
pub search_text_area: TextArea_<'a>,
pub commands_list_state: ListState,
pub history: Vec<histories::HistoryCommand>,
pub history_list_state: ListState,
pub additional_arguments_popup_state: Option<AdditionalWindowState<'a>>,
pub latest_version: Option<String>,
pub copy_command_state: Option<Result<String, String>>,
pub(super) preview_cache: PreviewCache,
}
impl PartialEq for SelectingCommandState<'_> {
fn eq(&self, other: &Self) -> bool {
let other_than_runners = self.current_pane == other.current_pane
&& self.search_text_area == other.search_text_area
&& self.commands_list_state == other.commands_list_state
&& self.history == other.history
&& self.history_list_state == other.history_list_state;
if !other_than_runners {
return false; }
let mut runner = false;
for (i, r) in self.runners.iter().enumerate() {
let other = other.runners.get(i).unwrap();
if r.path() == other.path() && r.list_commands() == other.list_commands() {
runner = true;
} else {
runner = false;
break;
}
}
runner
}
}
impl SelectingCommandState<'_> {
pub fn new(config: config::Config) -> Result<Self> {
let current_dir = match env::current_dir() {
Ok(d) => d,
Err(e) => bail!("Failed to get current directory: {}", e),
};
let current_pane = if config.get_focus_history() {
CurrentPane::History
} else {
CurrentPane::Main
};
let runners = {
let mut runners = vec![];
if let Ok(f) = Make::new(current_dir.clone()) {
runners.push(Runner::MakeCommand(f));
};
if let Some(js_package_manager) = js::get_js_package_manager_runner(current_dir.clone()) {
runners.push(Runner::JsPackageManager(js_package_manager));
};
if let Ok(just) = Just::new(current_dir.clone()) {
runners.push(Runner::Just(just));
};
if let Ok(task) = Task::new(current_dir.clone()) {
runners.push(Runner::Task(task));
};
runners
};
if runners.is_empty() {
Err(anyhow!(
"No task runner found.\nRun following command to see usage.\nopen \"https://github.com/kyu08/fzf-make?tab=readme-ov-file#-usage\""
))
} else {
Ok(SelectingCommandState {
current_dir: current_dir.clone(),
current_pane,
runners: runners.clone(),
search_text_area: TextArea_(TextArea::default()),
commands_list_state: ListState::with_selected(ListState::default(), Some(0)),
history: Model::get_histories(current_dir),
history_list_state: ListState::with_selected(ListState::default(), Some(0)),
additional_arguments_popup_state: None,
latest_version: None,
copy_command_state: None,
preview_cache: PreviewCache::default(),
})
}
}
pub(super) fn load_preview(&self) {
if let Some(command) = self.selected_command() {
self.preview_cache
.load(&command.file_path, command.runner_type.get_extension_for_highlighting());
}
}
pub fn get_selected_command(&self) -> Option<command::CommandForExec> {
match self.current_pane {
CurrentPane::Main => self.selected_command().map(|c| c.into()),
CurrentPane::History => self.selected_history().map(|c| c.into()),
}
}
fn move_to_next_pane(&mut self) {
match self.current_pane {
CurrentPane::Main => self.current_pane = CurrentPane::History,
CurrentPane::History => self.current_pane = CurrentPane::Main,
}
}
fn selected_command(&self) -> Option<command::CommandWithPreview> {
match self.commands_list_state.selected() {
Some(i) => self.narrow_down_commands().get(i).cloned(),
None => None,
}
}
fn selected_history(&self) -> Option<histories::HistoryCommand> {
let history = self.get_history();
if history.is_empty() {
return None;
}
match self.history_list_state.selected() {
Some(i) => history.get(i).cloned(),
None => None,
}
}
pub fn narrow_down_commands(&self) -> Vec<command::CommandWithPreview> {
let commands = {
let mut commands: Vec<command::CommandWithPreview> = Vec::new();
for runner in &self.runners {
commands = [commands, runner.list_commands()].concat();
}
commands
};
if self.search_text_area.0.is_empty() {
return commands;
}
let mut temporary_command_map: HashMap<String, command::CommandWithPreview> = HashMap::new();
for command in &commands {
temporary_command_map.insert(command.to_string(), command.clone());
}
let filtered_list: Vec<String> = {
let matcher = SkimMatcherV2::default();
let mut list: Vec<(i64, String)> = commands
.into_iter()
.filter_map(|command| {
let mut key_input = self.search_text_area.0.lines().join("");
key_input.retain(|c| !c.is_whitespace());
matcher
.fuzzy_indices(&command.to_string(), key_input.as_str())
.map(|(score, _)| (score, command.to_string()))
})
.collect();
list.sort_by(|(score1, _), (score2, _)| score2.cmp(score1));
list.into_iter().map(|(_, command)| command).collect()
};
let mut result: Vec<command::CommandWithPreview> = Vec::new();
for c in filtered_list {
if let Some(command) = temporary_command_map.get(&c) {
result.push(command.clone());
}
}
result
}
pub fn get_history(&self) -> Vec<histories::HistoryCommand> {
self.history.clone()
}
fn next_command(&mut self) {
if self.narrow_down_commands().is_empty() {
self.commands_list_state.select(None);
return;
}
let i = match self.commands_list_state.selected() {
Some(i) => {
if self.narrow_down_commands().len() - 1 <= i {
0
} else {
i + 1
}
}
None => 0,
};
self.commands_list_state.select(Some(i));
}
fn previous_command(&mut self) {
if self.narrow_down_commands().is_empty() {
self.commands_list_state.select(None);
return;
}
let i = match self.commands_list_state.selected() {
Some(i) => {
if i == 0 {
self.narrow_down_commands().len() - 1
} else {
i - 1
}
}
None => 0,
};
self.commands_list_state.select(Some(i));
}
fn next_history(&mut self) {
let history_list = self.get_history();
if history_list.is_empty() {
self.history_list_state.select(None);
return;
};
let i = match self.history_list_state.selected() {
Some(i) => {
if history_list.len() - 1 <= i {
0
} else {
i + 1
}
}
None => 0,
};
self.history_list_state.select(Some(i));
}
fn previous_history(&mut self) {
let history_list_len = self.get_history().len();
match history_list_len {
0 => {
self.history_list_state.select(None);
}
_ => {
let i = match self.history_list_state.selected() {
Some(i) => {
if i == 0 {
history_list_len - 1
} else {
i - 1
}
}
None => 0,
};
self.history_list_state.select(Some(i));
}
};
}
fn handle_key_input(&mut self, key_event: KeyEvent) {
match key_event.code {
KeyCode::Char(_) | KeyCode::Backspace => {
self.reset_selection();
}
_ => {}
}
self.search_text_area.0.input(key_event);
}
fn open_additional_arguments_popup(&mut self) {
if let Some(command) = self.get_selected_command()
&& self.additional_arguments_popup_state.is_none()
{
self.additional_arguments_popup_state = Some(AdditionalWindowState::new(command));
}
}
fn close_additional_arguments_popup(&mut self) {
if self.additional_arguments_popup_state.is_some() {
self.additional_arguments_popup_state = None;
}
}
fn handle_additional_arguments_key_input(&mut self, key_event: KeyEvent) {
if let Some(ref mut additional_window) = self.additional_arguments_popup_state {
additional_window.arguments_text_area.0.input(key_event);
}
}
fn copy_command_to_clipboard(&mut self) {
let command = match self.get_selected_command() {
Some(c) => c.to_string(),
None => {
return;
}
};
match Clipboard::new() {
Ok(mut clipboard) => match clipboard.set_text(&command) {
Ok(_) => self.copy_command_state = Some(Ok(command.to_string())),
Err(e) => {
self.copy_command_state = Some(Err(e.to_string()));
}
},
Err(e) => {
self.copy_command_state = Some(Err(e.to_string()));
}
}
}
fn store_history(&self, command: command::CommandForExec) {
if let Some((dir, file_name)) = toml::history_file_path() {
let all_histories = toml::Histories::get_history()
.into()
.append(self.current_dir.clone(), command);
let _ = toml::create_or_update_history_file(dir, file_name, all_histories);
};
}
fn reset_selection(&mut self) {
if self.narrow_down_commands().is_empty() {
self.commands_list_state.select(None);
}
self.commands_list_state.select(Some(0));
}
pub fn get_latest_command(&self) -> Option<&histories::HistoryCommand> {
self.history.first()
}
pub fn get_runner(&self, runner_type: &runner_type::RunnerType) -> Option<runner::Runner> {
for runner in &self.runners {
match (runner_type, runner) {
(runner_type::RunnerType::Make, runner::Runner::MakeCommand(_)) => {
return Some(runner.clone());
}
(
runner_type::RunnerType::JsPackageManager(runner_type_js),
runner::Runner::JsPackageManager(runner_js),
) => match (runner_type_js, runner_js) {
(runner_type::JsPackageManager::Npm, js::JsPackageManager::JsNpm(_)) => {
return Some(runner.clone());
}
(runner_type::JsPackageManager::Pnpm, js::JsPackageManager::JsPnpm(_)) => {
return Some(runner.clone());
}
(runner_type::JsPackageManager::Yarn, js::JsPackageManager::JsYarn(_)) => {
return Some(runner.clone());
}
(runner_type::JsPackageManager::Npm, js::JsPackageManager::JsPnpm(_))
| (runner_type::JsPackageManager::Npm, js::JsPackageManager::JsYarn(_))
| (runner_type::JsPackageManager::Pnpm, js::JsPackageManager::JsNpm(_))
| (runner_type::JsPackageManager::Pnpm, js::JsPackageManager::JsYarn(_))
| (runner_type::JsPackageManager::Yarn, js::JsPackageManager::JsNpm(_))
| (runner_type::JsPackageManager::Yarn, js::JsPackageManager::JsPnpm(_)) => return None,
},
_ => continue,
}
}
None
}
pub fn is_additional_arguments_popup_opened(&self) -> bool {
self.additional_arguments_popup_state.is_some()
}
#[cfg(test)]
fn new_for_test() -> Self {
use crate::model::runner_type;
SelectingCommandState {
current_dir: env::current_dir().unwrap(),
current_pane: CurrentPane::Main,
runners: vec![runner::Runner::MakeCommand(Make::new_for_test())],
search_text_area: TextArea_(TextArea::default()),
commands_list_state: ListState::with_selected(ListState::default(), Some(0)),
history: vec![
histories::HistoryCommand {
runner_type: runner_type::RunnerType::Make,
args: "history0".to_string(),
},
histories::HistoryCommand {
runner_type: runner_type::RunnerType::Make,
args: "history1".to_string(),
},
histories::HistoryCommand {
runner_type: runner_type::RunnerType::Make,
args: "history2".to_string(),
},
],
history_list_state: ListState::with_selected(ListState::default(), Some(0)),
additional_arguments_popup_state: None,
latest_version: None,
copy_command_state: None,
preview_cache: PreviewCache::default(),
}
}
}
#[derive(Debug, Clone)]
pub struct AdditionalWindowState<'a> {
pub arguments_text_area: TextArea_<'a>,
command: command::CommandForExec,
}
impl AdditionalWindowState<'_> {
pub fn new(command: command::CommandForExec) -> Self {
Self {
arguments_text_area: TextArea_(TextArea::default()),
command,
}
}
pub fn append_arguments(&self) -> command::CommandForExec {
let mut new_command = self.command.clone();
let arguments = self.arguments_text_area.0.lines().join(" ").trim().to_string();
if !arguments.is_empty() {
new_command.args.push_str(&format!(" {}", arguments));
}
new_command
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct SelectedCommandState {
executor: runner::Runner,
command: command::CommandForExec,
}
impl SelectedCommandState {
fn new(executor: runner::Runner, command: command::CommandForExec) -> Self {
SelectedCommandState { executor, command }
}
}
#[derive(Clone, PartialEq, Debug)]
pub enum CurrentPane {
Main,
History,
}
impl CurrentPane {
pub fn is_main(&self) -> bool {
matches!(self, CurrentPane::Main)
}
pub fn is_history(&self) -> bool {
matches!(self, CurrentPane::History)
}
}
#[derive(Clone, Debug)]
pub struct TextArea_<'a>(pub TextArea<'a>);
impl PartialEq for TextArea_<'_> {
fn eq(&self, other: &Self) -> bool {
self.0.lines().join("") == other.0.lines().join("")
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::model::runner_type;
use pretty_assertions::assert_eq;
use std::env;
#[test]
fn update_test() {
struct Case<'a> {
title: &'static str,
model: Model<'a>,
message: Option<Message>,
expect_model: Model<'a>,
}
let cases: Vec<Case> = vec![
Case {
title: "MoveToNextPane(Main -> History)",
model: Model {
app_state: AppState::SelectingCommand(Box::new(SelectingCommandState {
current_pane: CurrentPane::Main,
..SelectingCommandState::new_for_test()
})),
},
message: Some(Message::MoveToNextPane),
expect_model: Model {
app_state: AppState::SelectingCommand(Box::new(SelectingCommandState {
current_pane: CurrentPane::History,
..SelectingCommandState::new_for_test()
})),
},
},
Case {
title: "MoveToNextPane(History -> Main)",
model: Model {
app_state: AppState::SelectingCommand(Box::new(SelectingCommandState {
current_pane: CurrentPane::History,
..SelectingCommandState::new_for_test()
})),
},
message: Some(Message::MoveToNextPane),
expect_model: Model {
app_state: AppState::SelectingCommand(Box::new(SelectingCommandState {
current_pane: CurrentPane::Main,
..SelectingCommandState::new_for_test()
})),
},
},
Case {
title: "Quit",
model: Model {
app_state: AppState::SelectingCommand(Box::new(SelectingCommandState {
..SelectingCommandState::new_for_test()
})),
},
message: Some(Message::Quit),
expect_model: Model {
app_state: AppState::Quitting(Ok(())),
},
},
Case {
title: "NoCommandSelected",
model: Model {
app_state: AppState::SelectingCommand(Box::new(SelectingCommandState {
..SelectingCommandState::new_for_test()
})),
},
message: Some(Message::NoCommandSelected),
expect_model: Model {
app_state: AppState::Quitting(Err(anyhow!("No command selected"))),
},
},
Case {
title: "SearchTextAreaKeyInput(a)",
model: Model {
app_state: AppState::SelectingCommand(Box::new(SelectingCommandState {
..SelectingCommandState::new_for_test()
})),
},
message: Some(Message::SearchTextAreaKeyInput(KeyEvent::from(KeyCode::Char('a')))),
expect_model: Model {
app_state: AppState::SelectingCommand(Box::new(SelectingCommandState {
search_text_area: {
let mut text_area = TextArea::default();
text_area.input(KeyEvent::from(KeyCode::Char('a')));
TextArea_(text_area)
},
..SelectingCommandState::new_for_test()
})),
},
},
Case {
title: "when BackSpace is inputted, the selection should be reset",
model: Model {
app_state: AppState::SelectingCommand(Box::new(SelectingCommandState {
commands_list_state: ListState::with_selected(ListState::default(), Some(1)),
..SelectingCommandState::new_for_test()
})),
},
message: Some(Message::SearchTextAreaKeyInput(KeyEvent::from(KeyCode::Backspace))),
expect_model: Model {
app_state: AppState::SelectingCommand(Box::new(SelectingCommandState {
commands_list_state: ListState::with_selected(ListState::default(), Some(0)),
..SelectingCommandState::new_for_test()
})),
},
},
Case {
title: "Next(0 -> 1)",
model: Model {
app_state: AppState::SelectingCommand(Box::new(SelectingCommandState {
..SelectingCommandState::new_for_test()
})),
},
message: Some(Message::NextCommand),
expect_model: Model {
app_state: AppState::SelectingCommand(Box::new(SelectingCommandState {
commands_list_state: ListState::with_selected(ListState::default(), Some(1)),
..SelectingCommandState::new_for_test()
})),
},
},
Case {
title: "Next(2 -> 0)",
model: Model {
app_state: AppState::SelectingCommand(Box::new(SelectingCommandState {
commands_list_state: ListState::with_selected(ListState::default(), Some(2)),
..SelectingCommandState::new_for_test()
})),
},
message: Some(Message::NextCommand),
expect_model: Model {
app_state: AppState::SelectingCommand(Box::new(SelectingCommandState {
commands_list_state: ListState::with_selected(ListState::default(), Some(0)),
..SelectingCommandState::new_for_test()
})),
},
},
Case {
title: "Previous(1 -> 0)",
model: Model {
app_state: AppState::SelectingCommand(Box::new(SelectingCommandState {
commands_list_state: ListState::with_selected(ListState::default(), Some(1)),
..SelectingCommandState::new_for_test()
})),
},
message: Some(Message::PreviousCommand),
expect_model: Model {
app_state: AppState::SelectingCommand(Box::new(SelectingCommandState {
commands_list_state: ListState::with_selected(ListState::default(), Some(0)),
..SelectingCommandState::new_for_test()
})),
},
},
Case {
title: "Previous(0 -> 2)",
model: Model {
app_state: AppState::SelectingCommand(Box::new(SelectingCommandState {
commands_list_state: ListState::with_selected(ListState::default(), Some(0)),
..SelectingCommandState::new_for_test()
})),
},
message: Some(Message::PreviousCommand),
expect_model: Model {
app_state: AppState::SelectingCommand(Box::new(SelectingCommandState {
commands_list_state: ListState::with_selected(ListState::default(), Some(2)),
..SelectingCommandState::new_for_test()
})),
},
},
Case {
title: "ExecuteCommand(Main)",
model: Model {
app_state: AppState::SelectingCommand(Box::new(SelectingCommandState {
..SelectingCommandState::new_for_test()
})),
},
message: Some(Message::ExecuteCommand(command::CommandForExec {
runner_type: runner_type::RunnerType::Make,
args: "target0".to_string(),
})),
expect_model: Model {
app_state: AppState::SelectedCommand(SelectedCommandState::new(
runner::Runner::MakeCommand(Make::new_for_test()),
command::CommandForExec {
runner_type: runner_type::RunnerType::Make,
args: "target0".to_string(),
},
)),
},
},
Case {
title: "ExecuteCommand(History)",
model: Model {
app_state: AppState::SelectingCommand(Box::new(SelectingCommandState {
current_pane: CurrentPane::History,
history_list_state: ListState::with_selected(ListState::default(), Some(1)),
..SelectingCommandState::new_for_test()
})),
},
message: Some(Message::ExecuteCommand(command::CommandForExec {
runner_type: runner_type::RunnerType::Make,
args: "history1".to_string(),
})),
expect_model: Model {
app_state: AppState::SelectedCommand(SelectedCommandState::new(
runner::Runner::MakeCommand(Make::new_for_test()),
command::CommandForExec {
runner_type: runner_type::RunnerType::Make,
args: "history1".to_string(),
},
)),
},
},
Case {
title: "Selecting position should be reset if some kind of char
was inputted when the command located not in top of the commands",
model: Model {
app_state: AppState::SelectingCommand(Box::new(SelectingCommandState {
commands_list_state: ListState::with_selected(ListState::default(), Some(1)),
..SelectingCommandState::new_for_test()
})),
},
message: Some(Message::SearchTextAreaKeyInput(KeyEvent::from(KeyCode::Char('a')))),
expect_model: Model {
app_state: AppState::SelectingCommand(Box::new(SelectingCommandState {
commands_list_state: ListState::with_selected(ListState::default(), Some(0)),
search_text_area: {
let mut text_area = TextArea::default();
text_area.input(KeyEvent::from(KeyCode::Char('a')));
TextArea_(text_area)
},
..SelectingCommandState::new_for_test()
})),
},
},
Case {
title: "NextCommand when there is no commands to select, panic should not occur",
model: {
let mut m = Model {
app_state: AppState::SelectingCommand(Box::new(SelectingCommandState {
commands_list_state: ListState::with_selected(ListState::default(), None),
..SelectingCommandState::new_for_test()
})),
};
update(
&mut m,
Some(Message::SearchTextAreaKeyInput(KeyEvent::from(KeyCode::Char('w')))),
);
m
},
message: Some(Message::NextCommand),
expect_model: Model {
app_state: AppState::SelectingCommand(Box::new(SelectingCommandState {
commands_list_state: ListState::with_selected(ListState::default(), None),
search_text_area: {
let mut text_area = TextArea::default();
text_area.input(KeyEvent::from(KeyCode::Char('w')));
TextArea_(text_area)
},
..SelectingCommandState::new_for_test()
})),
},
},
Case {
title: "PreviousCommand when there is no commands to select,
panic should not occur",
model: {
let mut m = Model {
app_state: AppState::SelectingCommand(Box::new(SelectingCommandState {
commands_list_state: ListState::with_selected(ListState::default(), None),
..SelectingCommandState::new_for_test()
})),
};
update(
&mut m,
Some(Message::SearchTextAreaKeyInput(KeyEvent::from(KeyCode::Char('w')))),
);
m
},
message: Some(Message::PreviousCommand),
expect_model: Model {
app_state: AppState::SelectingCommand(Box::new(SelectingCommandState {
commands_list_state: ListState::with_selected(ListState::default(), None),
search_text_area: {
let mut text_area = TextArea::default();
text_area.input(KeyEvent::from(KeyCode::Char('w')));
TextArea_(text_area)
},
..SelectingCommandState::new_for_test()
})),
},
},
Case {
title: "NextHistory",
model: Model {
app_state: AppState::SelectingCommand(Box::new(SelectingCommandState {
current_pane: CurrentPane::History,
history_list_state: ListState::with_selected(ListState::default(), Some(0)),
..SelectingCommandState::new_for_test()
})),
},
message: Some(Message::NextHistory),
expect_model: Model {
app_state: AppState::SelectingCommand(Box::new(SelectingCommandState {
current_pane: CurrentPane::History,
history_list_state: ListState::with_selected(ListState::default(), Some(1)),
..SelectingCommandState::new_for_test()
})),
},
},
Case {
title: "PreviousHistory",
model: Model {
app_state: AppState::SelectingCommand(Box::new(SelectingCommandState {
current_pane: CurrentPane::History,
history_list_state: ListState::with_selected(ListState::default(), Some(0)),
..SelectingCommandState::new_for_test()
})),
},
message: Some(Message::NextHistory),
expect_model: Model {
app_state: AppState::SelectingCommand(Box::new(SelectingCommandState {
current_pane: CurrentPane::History,
history_list_state: ListState::with_selected(ListState::default(), Some(1)),
..SelectingCommandState::new_for_test()
})),
},
},
Case {
title: "When the last history is selected and NextHistory is received,
it returns to the beginning.",
model: Model {
app_state: AppState::SelectingCommand(Box::new(SelectingCommandState {
current_pane: CurrentPane::History,
history_list_state: ListState::with_selected(ListState::default(), Some(2)),
..SelectingCommandState::new_for_test()
})),
},
message: Some(Message::NextHistory),
expect_model: Model {
app_state: AppState::SelectingCommand(Box::new(SelectingCommandState {
current_pane: CurrentPane::History,
history_list_state: ListState::with_selected(ListState::default(), Some(0)),
..SelectingCommandState::new_for_test()
})),
},
},
Case {
title: "When the first history is selected and PreviousHistory is received,
it moves to the last history.",
model: Model {
app_state: AppState::SelectingCommand(Box::new(SelectingCommandState {
current_pane: CurrentPane::History,
history_list_state: ListState::with_selected(ListState::default(), Some(0)),
..SelectingCommandState::new_for_test()
})),
},
message: Some(Message::PreviousHistory),
expect_model: Model {
app_state: AppState::SelectingCommand(Box::new(SelectingCommandState {
current_pane: CurrentPane::History,
history_list_state: ListState::with_selected(ListState::default(), Some(2)),
..SelectingCommandState::new_for_test()
})),
},
},
];
unsafe { env::set_var("FZF_MAKE_IS_TESTING", "true") };
for mut case in cases {
update(&mut case.model, case.message);
assert_eq!(case.expect_model, case.model, "\nFailed: 🚨{:?}🚨\n", case.title,);
}
}
#[test]
fn app_state_partial_eq_test() {
struct Case<'a> {
title: &'static str,
left: AppState<'a>,
right: AppState<'a>,
expect: bool,
}
let cases: Vec<Case> = vec![
Case {
title: "Quitting(Ok) == Quitting(Ok)",
left: AppState::Quitting(Ok(())),
right: AppState::Quitting(Ok(())),
expect: true,
},
Case {
title: "Quitting(Err) with the same message should be equal",
left: AppState::Quitting(Err(anyhow!("No command selected"))),
right: AppState::Quitting(Err(anyhow!("No command selected"))),
expect: true,
},
Case {
title: "Quitting(Err) with different messages should not be equal",
left: AppState::Quitting(Err(anyhow!("No command selected"))),
right: AppState::Quitting(Err(anyhow!("other error"))),
expect: false,
},
Case {
title: "Quitting(Ok) != Quitting(Err)",
left: AppState::Quitting(Ok(())),
right: AppState::Quitting(Err(anyhow!("No command selected"))),
expect: false,
},
Case {
title: "Quitting != SelectingCommand",
left: AppState::Quitting(Ok(())),
right: AppState::SelectingCommand(Box::new(SelectingCommandState::new_for_test())),
expect: false,
},
];
unsafe { env::set_var("FZF_MAKE_IS_TESTING", "true") };
for case in cases {
assert_eq!(case.expect, case.left == case.right, "\nFailed: 🚨{:?}🚨\n", case.title,);
}
}
#[test]
fn should_quit_test() {
unsafe { env::set_var("FZF_MAKE_IS_TESTING", "true") };
let model = Model {
app_state: AppState::SelectingCommand(Box::new(SelectingCommandState::new_for_test())),
};
assert!(model.should_quit().is_none());
let model = Model {
app_state: AppState::Quitting(Ok(())),
};
assert!(matches!(model.should_quit(), Some(Ok(()))));
let model = Model {
app_state: AppState::Quitting(Err(anyhow!("No command selected"))),
};
match model.should_quit() {
Some(Err(e)) => assert_eq!("No command selected", e.to_string()),
other => panic!("expected Some(Err(_)), got {:?}", other),
}
}
#[test]
fn narrow_down_commands_test() {
struct Case {
title: &'static str,
search_text: &'static str,
expect: Vec<&'static str>,
}
let cases: Vec<Case> = vec![
Case {
title: "all the commands are listed in the original order when the search text is empty",
search_text: "",
expect: vec!["target0", "target1", "target2"],
},
Case {
title: "commands with the same score keep the original order",
search_text: "target",
expect: vec!["target0", "target1", "target2"],
},
Case {
title: "commands which do not match the search text are filtered out",
search_text: "target2",
expect: vec!["target2"],
},
];
unsafe { env::set_var("FZF_MAKE_IS_TESTING", "true") };
for case in cases {
let state = SelectingCommandState {
search_text_area: {
let mut text_area = TextArea::default();
text_area.insert_str(case.search_text);
TextArea_(text_area)
},
..SelectingCommandState::new_for_test()
};
let actual: Vec<String> = state.narrow_down_commands().iter().map(|c| c.args.clone()).collect();
assert_eq!(case.expect, actual, "\nFailed: 🚨{:?}🚨\n", case.title,);
}
}
}