use std::fs;
use std::path::{Path, PathBuf};
use nucleo::pattern::{CaseMatching, Normalization, Pattern};
use nucleo::{Config, Matcher, Utf32Str};
use thiserror::Error;
use crate::app::history::NavigationHistory;
use crate::app::mode::Mode;
use crate::app::tabs::TabManager;
use crate::config::TrailConfig;
use crate::input::command_parser::{CommandHistory, TabState};
use crate::plugin::bookmarks::{BookmarkError, BookmarkStore};
use crate::preview::provider::PreviewContent;
#[derive(Debug, Error)]
pub enum StateError {
#[error("failed to read directory {path}: {source}")]
ReadDir {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("filesystem error: {0}")]
FsOp(#[from] crate::actions::fs_ops::FsError),
#[error("config error: {0}")]
Config(#[from] crate::config::ConfigError),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EntryKind {
Dir,
File,
Symlink,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GitFileStatus {
#[allow(dead_code)]
Untracked,
#[allow(dead_code)]
Modified,
#[allow(dead_code)]
Added,
#[allow(dead_code)]
Deleted,
#[allow(dead_code)]
Renamed,
Clean,
}
#[derive(Debug, Clone)]
pub struct Entry {
pub path: PathBuf,
pub file_name: String,
pub kind: EntryKind,
pub is_hidden: bool,
pub metadata: Option<fs::Metadata>,
pub git_status: Option<GitFileStatus>,
pub is_text: Option<bool>,
}
impl Entry {
fn from_dir_entry(de: &fs::DirEntry) -> Option<Self> {
let path = de.path();
let file_name = path.file_name()?.to_str()?.to_owned();
let is_hidden = file_name.starts_with('.');
let metadata = de.metadata().ok();
let kind = if let Some(ref m) = metadata {
if m.is_symlink() {
EntryKind::Symlink
} else if m.is_dir() {
EntryKind::Dir
} else {
EntryKind::File
}
} else {
EntryKind::File
};
Some(Entry {
path,
file_name,
kind,
is_hidden,
metadata,
git_status: None,
is_text: None,
})
}
}
#[derive(Debug, Clone)]
pub struct GitDirState {
pub branch: String,
pub is_dirty: bool,
}
#[derive(Debug, Clone, Default)]
pub struct PreviewSlot {
pub for_path: PathBuf,
pub generation: u64,
pub content: PreviewContent,
pub scroll: usize,
pub viewport_height: usize,
pub viewport_width: u16,
pub truncated: bool,
pub drew_image: bool,
}
const PAGE_OVERLAP_LINES: usize = 2;
impl PreviewSlot {
pub fn begin(&mut self, path: &Path) -> u64 {
self.generation = self.generation.wrapping_add(1);
if self.for_path != path {
self.for_path = path.to_path_buf();
self.scroll = 0;
self.truncated = false;
}
self.generation
}
pub fn max_scroll(&self) -> usize {
let len = self.content.scrollable_len().unwrap_or(0);
len - self.lines_fitting_before(len)
}
pub fn lines_visible_from(&self, start: usize) -> usize {
let len = self.content.scrollable_len().unwrap_or(0);
let mut rows = 0;
let mut count = 0;
for index in start..len {
rows += self.content.display_rows(index, self.viewport_width);
if rows > self.viewport_height {
break;
}
count += 1;
}
self.at_least_one(count, start < len)
}
fn lines_fitting_before(&self, end: usize) -> usize {
let mut rows = 0;
let mut count = 0;
for index in (0..end).rev() {
rows += self.content.display_rows(index, self.viewport_width);
if rows > self.viewport_height {
break;
}
count += 1;
}
self.at_least_one(count, end > 0)
}
fn at_least_one(&self, count: usize, has_line: bool) -> usize {
if has_line && self.viewport_height > 0 {
count.max(1)
} else {
count
}
}
pub fn clamp_scroll(&mut self) {
if self.content.scrollable_len().is_none() {
return;
}
self.scroll = self.scroll.min(self.max_scroll());
}
fn page_down_from(&self, from: usize) -> usize {
let step = self
.lines_visible_from(from)
.saturating_sub(PAGE_OVERLAP_LINES)
.max(1);
from.saturating_add(step)
}
fn page_up_from(&self, from: usize) -> usize {
let len = self.content.scrollable_len().unwrap_or(0);
let end = from.saturating_add(PAGE_OVERLAP_LINES).min(len);
let target = end.saturating_sub(self.lines_fitting_before(end));
target.min(from.saturating_sub(1))
}
}
fn offset_by(base: usize, delta: isize) -> usize {
if delta >= 0 {
base.saturating_add(delta as usize)
} else {
base.saturating_sub(delta.unsigned_abs())
}
}
#[derive(Debug, Clone, Default)]
pub struct FilterState {
pub query: String,
pub matches: Vec<usize>,
pub scores: Vec<u32>,
}
#[derive(Debug, Clone, Default)]
pub struct StatusBarState {
pub cwd_display: String,
pub entry_count: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NoticeLevel {
Info,
Error,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Notice {
pub text: String,
pub level: NoticeLevel,
}
#[derive(Debug)]
pub struct AppState {
pub config: TrailConfig,
pub cwd: PathBuf,
pub launch_dir: PathBuf,
pub entries: Vec<Entry>,
pub selected: usize,
pub nav_scroll: Option<crate::app::scroll::NavScroll>,
pub mode: Mode,
pub history: NavigationHistory,
pub filter: Option<FilterState>,
pub preview: PreviewSlot,
pub git: Option<GitDirState>,
pub status: StatusBarState,
pub show_hidden: bool,
pub sort: crate::app::sort::SortSettings,
pub dirty: bool,
pub selection_memory: std::collections::HashMap<PathBuf, PathBuf>,
pub pending_delete: bool,
pub notice: Option<Notice>,
pub last_yank: Option<String>,
pub command_history: CommandHistory,
pub tab_state: TabState,
pub pending_nav_key: Option<char>,
pub pending_external: Option<crate::actions::Action>,
pub tab_manager: TabManager,
pub bookmark_store: Option<BookmarkStore>,
pub plugin_engine: Option<crate::plugin::PluginEngine>,
pub recent_dirs: crate::session::RecentDirs,
pub terminal: crate::terminal::TerminalPanel,
pub transfers: crate::actions::transfer::Transfers,
pub marks: crate::app::marks::MarkSet,
pub settings: crate::config::persist::SessionSettings,
pub last_repeatable: Option<crate::actions::Repeatable>,
}
impl AppState {
#[allow(dead_code)]
pub fn new(start_path: PathBuf) -> Result<Self, StateError> {
let config = crate::config::load(None)?;
Self::with_config(start_path, config)
}
pub fn with_config(start_path: PathBuf, config: TrailConfig) -> Result<Self, StateError> {
let cwd = crate::pathfmt::canonicalize(&start_path).unwrap_or(start_path);
let launch_dir = std::env::current_dir()
.map(|dir| crate::pathfmt::canonicalize(&dir).unwrap_or(dir))
.unwrap_or_else(|e| {
tracing::debug!("could not read the launch directory: {e}");
cwd.clone()
});
let sort = config.navigation.sort_settings();
let mut state = AppState {
config,
cwd: cwd.clone(),
launch_dir,
entries: Vec::new(),
selected: 0,
nav_scroll: None,
mode: Mode::default(),
history: NavigationHistory::new(),
filter: None,
preview: PreviewSlot::default(),
git: None,
status: StatusBarState::default(),
show_hidden: false,
sort,
dirty: true,
selection_memory: std::collections::HashMap::new(),
pending_delete: false,
notice: None,
last_yank: None,
command_history: CommandHistory::new(),
tab_state: TabState::new(),
pending_nav_key: None,
pending_external: None,
tab_manager: TabManager::new(cwd.clone(), sort),
bookmark_store: None,
plugin_engine: None,
recent_dirs: crate::session::RecentDirs::default(),
terminal: crate::terminal::TerminalPanel::default(),
transfers: crate::actions::transfer::Transfers::default(),
marks: crate::app::marks::MarkSet::default(),
settings: crate::config::persist::SessionSettings::default(),
last_repeatable: None,
};
state.load_dir(&cwd)?;
Ok(state)
}
pub fn apply_filter(&mut self, query: String) {
let mut filter = FilterState {
query: query.clone(),
matches: Vec::new(),
scores: Vec::new(),
};
if query.is_empty() {
filter.matches = (0..self.entries.len())
.filter(|&i| self.show_hidden || !self.entries[i].is_hidden)
.collect();
filter.scores = vec![0u32; filter.matches.len()];
} else {
let pattern = Pattern::parse(&query, CaseMatching::Ignore, Normalization::Smart);
let mut matcher = Matcher::new(Config::DEFAULT);
let mut scored: Vec<(usize, u32)> = self
.entries
.iter()
.enumerate()
.filter(|(_, e)| self.show_hidden || !e.is_hidden)
.filter_map(|(idx, entry)| {
let mut buf = Vec::new();
let haystack = Utf32Str::new(&entry.file_name, &mut buf);
pattern.score(haystack, &mut matcher).map(|s| (idx, s))
})
.collect();
scored.sort_by_key(|b| std::cmp::Reverse(b.1));
filter.matches = scored.iter().map(|(idx, _)| *idx).collect();
filter.scores = scored.iter().map(|(_, score)| *score).collect();
}
self.selected = 0;
self.filter = Some(filter);
if let Mode::Search {
query: q,
matches: m,
} = &mut self.mode
{
*q = query;
*m = self
.filter
.as_ref()
.map(|f| f.matches.clone())
.unwrap_or_default();
}
self.dirty = true;
}
pub fn clear_filter(&mut self) {
if self.filter.is_none() {
return;
}
let anchor = self.selected_entry().map(|e| e.path.clone());
self.filter = None;
self.restore_selection(anchor.as_deref(), 0);
self.dirty = true;
}
pub fn filtered_count(&self) -> usize {
match &self.filter {
Some(f) => f.matches.len(),
None => self.visible_count(),
}
}
pub fn filtered_entries(&self) -> impl Iterator<Item = (usize, &Entry)> {
let pairs: Vec<(usize, &Entry)> = match &self.filter {
Some(f) => f
.matches
.iter()
.filter_map(|&idx| self.entries.get(idx).map(|e| (idx, e)))
.collect(),
None => self
.entries
.iter()
.enumerate()
.filter(|(_, e)| self.show_hidden || !e.is_hidden)
.collect(),
};
pairs.into_iter()
}
pub fn selected_filtered_entry(&self) -> Option<&Entry> {
self.filtered_entries().nth(self.selected).map(|(_, e)| e)
}
pub fn load_dir(&mut self, path: &Path) -> Result<(), StateError> {
let read = fs::read_dir(path).map_err(|e| StateError::ReadDir {
path: path.to_owned(),
source: e,
})?;
let mut entries: Vec<Entry> = read
.filter_map(|res| res.ok())
.filter_map(|de| Entry::from_dir_entry(&de))
.collect();
crate::app::sort::sort_entries(&mut entries, self.sort);
self.entries = entries;
let remembered = self.selection_memory.get(path).cloned();
self.restore_selection(remembered.as_deref(), 0);
self.update_status();
self.dirty = true;
Ok(())
}
fn index_of_path(&self, path: &Path) -> Option<usize> {
if self.filter.is_some() {
self.filtered_entries().position(|(_, e)| e.path == path)
} else {
self.visible_entries().position(|e| e.path == path)
}
}
fn restore_selection(&mut self, anchor: Option<&Path>, fallback: usize) {
let count = self.filtered_count();
if count == 0 {
self.selected = 0;
return;
}
let found = anchor.and_then(|path| self.index_of_path(path));
self.selected = found.unwrap_or_else(|| fallback.min(count - 1));
}
pub fn classify_entry(&mut self, path: &Path, is_text: bool) {
if let Some(entry) = self.entries.iter_mut().find(|e| e.path == path) {
entry.is_text = Some(is_text);
}
}
fn remember_selection(&mut self) {
if let Some(path) = self.selected_entry().map(|e| e.path.clone()) {
self.selection_memory.insert(self.cwd.clone(), path);
}
}
pub fn visible_count(&self) -> usize {
if self.show_hidden {
self.entries.len()
} else {
self.entries.iter().filter(|e| !e.is_hidden).count()
}
}
pub fn visible_entries(&self) -> impl Iterator<Item = &Entry> {
self.entries
.iter()
.filter(move |e| self.show_hidden || !e.is_hidden)
}
pub fn selected_entry(&self) -> Option<&Entry> {
if self.filter.is_some() {
self.selected_filtered_entry()
} else {
self.visible_entries().nth(self.selected)
}
}
pub fn enter_dir(&mut self, path: PathBuf) -> Result<(), StateError> {
let path = crate::pathfmt::simplified(&path);
self.remember_selection();
self.history.push(self.cwd.clone());
self.cwd = path.clone();
let res = self.load_dir(&self.cwd.clone());
self.recent_dirs.visit(path.clone());
if let Some(engine) = &self.plugin_engine {
engine.fire_on_enter_dir(&path);
}
res
}
pub fn go_parent(&mut self) -> Result<(), StateError> {
if let Some(parent) = self.cwd.parent().map(|p| p.to_owned()) {
self.remember_selection();
self.history.push(self.cwd.clone());
self.cwd = parent;
self.load_dir(&self.cwd.clone())?;
}
Ok(())
}
pub fn history_back(&mut self) -> Result<(), StateError> {
if let Some(prev) = self.history.back(self.cwd.clone()) {
self.remember_selection();
self.cwd = prev;
self.load_dir(&self.cwd.clone())?;
}
Ok(())
}
pub fn history_forward(&mut self) -> Result<(), StateError> {
if let Some(next) = self.history.forward(self.cwd.clone()) {
self.remember_selection();
self.cwd = next;
self.load_dir(&self.cwd.clone())?;
}
Ok(())
}
pub fn move_down(&mut self) {
let count = self.filtered_count();
if count == 0 {
return;
}
let new = (self.selected + 1).min(count - 1);
if new != self.selected {
self.selected = new;
self.dirty = true;
}
}
pub fn move_up(&mut self) {
if self.selected > 0 {
self.selected -= 1;
self.dirty = true;
}
}
pub fn jump_top(&mut self) {
if self.selected != 0 {
self.selected = 0;
self.dirty = true;
}
}
pub fn jump_bottom(&mut self) {
let count = self.filtered_count();
if count == 0 {
return;
}
let last = count - 1;
if self.selected != last {
self.selected = last;
self.dirty = true;
}
}
pub fn scroll_preview_lines(&mut self, delta: isize) {
self.set_preview_scroll(offset_by(self.preview.scroll, delta));
}
pub fn scroll_preview_pages(&mut self, pages: isize) {
let mut target = self.preview.scroll;
for _ in 0..pages.unsigned_abs() {
target = if pages > 0 {
self.preview.page_down_from(target)
} else {
self.preview.page_up_from(target)
};
}
self.set_preview_scroll(target);
}
pub fn scroll_preview_to_edge(&mut self, to_end: bool) {
let target = if to_end { self.preview.max_scroll() } else { 0 };
self.set_preview_scroll(target);
}
fn set_preview_scroll(&mut self, target: usize) {
if self.preview.content.scrollable_len().is_none() {
return;
}
let new = target.min(self.preview.max_scroll());
if new != self.preview.scroll {
self.preview.scroll = new;
self.dirty = true;
}
}
pub fn toggle_hidden(&mut self) -> Result<(), StateError> {
let anchor = self.selected_entry().map(|e| e.path.clone());
self.show_hidden = !self.show_hidden;
if let Some(f) = self.filter.take() {
let q = f.query.clone();
self.apply_filter(q);
}
self.restore_selection(anchor.as_deref(), self.selected);
self.dirty = true;
Ok(())
}
pub fn set_sort(&mut self, settings: crate::app::sort::SortSettings) {
let anchor = self.selected_entry().map(|e| e.path.clone());
self.sort = settings;
self.tab_manager.active_tab_mut().sort = settings;
crate::app::sort::sort_entries(&mut self.entries, settings);
if let Some(f) = self.filter.take() {
let q = f.query.clone();
self.apply_filter(q);
}
self.restore_selection(anchor.as_deref(), self.selected);
self.dirty = true;
}
pub fn refresh(&mut self) -> Result<(), StateError> {
let anchor = self.selected_entry().map(|e| e.path.clone());
let saved = self.selected;
self.load_dir(&self.cwd.clone())?;
self.restore_selection(anchor.as_deref(), saved);
self.dirty = true;
Ok(())
}
pub fn notify(&mut self, text: impl Into<String>) {
let text = text.into();
tracing::info!(%text, "notice");
self.notice = Some(Notice {
text,
level: NoticeLevel::Info,
});
self.dirty = true;
}
pub fn set_error(&mut self, text: impl Into<String>) {
let text = text.into();
tracing::warn!(%text, "error notice");
self.notice = Some(Notice {
text,
level: NoticeLevel::Error,
});
self.dirty = true;
}
pub fn clear_notice(&mut self) -> bool {
if self.notice.is_none() {
return false;
}
self.notice = None;
self.dirty = true;
true
}
#[allow(dead_code)]
pub fn error_text(&self) -> Option<&str> {
match &self.notice {
Some(Notice {
text,
level: NoticeLevel::Error,
}) => Some(text.as_str()),
_ => None,
}
}
fn update_status(&mut self) {
self.status.cwd_display = crate::pathfmt::display(&self.cwd);
self.status.entry_count = self.visible_count();
}
fn save_to_active_tab(&mut self) {
let tab = self.tab_manager.active_tab_mut();
tab.cwd = self.cwd.clone();
tab.entries = self.entries.clone();
tab.selected = self.selected;
tab.sort = self.sort;
std::mem::swap(&mut tab.history, &mut self.history);
}
pub fn restore_from_active_tab(&mut self) -> Result<(), StateError> {
let tab = self.tab_manager.active_tab_mut();
let new_cwd = tab.cwd.clone();
let new_selected = tab.selected;
let new_sort = tab.sort;
std::mem::swap(&mut tab.history, &mut self.history);
self.cwd = new_cwd.clone();
self.selected = new_selected;
self.sort = new_sort;
self.git = None;
self.filter = None;
self.preview = PreviewSlot::default();
self.mode = Mode::Navigation;
self.load_dir(&new_cwd)?;
Ok(())
}
pub fn open_tab(&mut self, cwd: Option<PathBuf>) -> Result<(), StateError> {
let new_cwd = cwd
.map(|p| crate::pathfmt::simplified(&p))
.unwrap_or_else(|| self.cwd.clone());
self.save_to_active_tab();
self.tab_manager.open_tab(new_cwd.clone(), self.sort);
self.cwd = new_cwd.clone();
self.selected = 0;
self.git = None;
self.filter = None;
self.preview = PreviewSlot::default();
self.mode = Mode::Navigation;
self.history = NavigationHistory::new();
self.load_dir(&new_cwd)?;
Ok(())
}
pub fn close_tab(&mut self) -> Result<bool, StateError> {
if !self.tab_manager.close_active_tab() {
return Ok(false);
}
let tab = self.tab_manager.active_tab_mut();
let new_cwd = tab.cwd.clone();
let new_selected = tab.selected;
let new_sort = tab.sort;
std::mem::swap(&mut tab.history, &mut self.history);
self.cwd = new_cwd.clone();
self.selected = new_selected;
self.sort = new_sort;
self.git = None;
self.filter = None;
self.preview = PreviewSlot::default();
self.mode = Mode::Navigation;
self.load_dir(&new_cwd)?;
Ok(true)
}
pub fn switch_tab_next(&mut self) -> Result<(), StateError> {
if self.tab_manager.is_single() {
return Ok(());
}
self.save_to_active_tab();
self.tab_manager.switch_next();
self.restore_from_active_tab()
}
pub fn switch_tab_prev(&mut self) -> Result<(), StateError> {
if self.tab_manager.is_single() {
return Ok(());
}
self.save_to_active_tab();
self.tab_manager.switch_prev();
self.restore_from_active_tab()
}
pub fn bookmark_add(&mut self, name: String) -> Result<(), BookmarkError> {
let cwd = self.cwd.clone();
if let Some(store) = &mut self.bookmark_store {
store.add(name, cwd)?;
} else {
tracing::debug!("bookmark_add: store not initialized");
}
Ok(())
}
pub fn bookmark_jump(&mut self, name: &str) -> Result<bool, StateError> {
let target = match &self.bookmark_store {
Some(store) => store.get(name).map(|p| p.to_owned()),
None => {
tracing::debug!("bookmark_jump: store not initialized");
return Ok(false);
}
};
match target {
Some(path) => {
if crate::pathfmt::simplified(&path) != self.cwd {
self.history.push_jump(self.cwd.clone());
}
self.enter_dir(path)?;
Ok(true)
}
None => Ok(false),
}
}
pub fn return_from_jump(&mut self) -> Result<Option<usize>, StateError> {
let mut skipped = 0;
while let Some(dir) = self.history.pop_jump() {
if dir.is_dir() {
self.enter_dir(dir)?;
return Ok(Some(skipped));
}
skipped += 1;
}
Ok(if skipped > 0 { Some(skipped) } else { None })
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn make_test_dir() -> TempDir {
let dir = tempfile::tempdir().expect("tempdir");
let p = dir.path();
fs::create_dir(p.join("alpha_dir")).unwrap();
fs::create_dir(p.join("zeta_dir")).unwrap();
fs::write(p.join("a_file.txt"), b"").unwrap();
fs::write(p.join(".hidden_file"), b"").unwrap();
dir
}
#[test]
fn dir_first_sort() {
let dir = make_test_dir();
let state = AppState::new(dir.path().to_owned()).unwrap();
let entries: Vec<_> = state.visible_entries().collect();
assert_eq!(entries[0].kind, EntryKind::Dir);
assert_eq!(entries[1].kind, EntryKind::Dir);
assert_eq!(entries[2].kind, EntryKind::File);
}
#[test]
fn dir_sort_is_alphabetical_case_insensitive() {
let dir = make_test_dir();
let state = AppState::new(dir.path().to_owned()).unwrap();
let dirs: Vec<_> = state
.visible_entries()
.filter(|e| e.kind == EntryKind::Dir)
.map(|e| e.file_name.as_str())
.collect();
assert_eq!(dirs, vec!["alpha_dir", "zeta_dir"]);
}
#[test]
fn hidden_files_hidden_by_default() {
let dir = make_test_dir();
let state = AppState::new(dir.path().to_owned()).unwrap();
assert!(
state.visible_entries().all(|e| !e.is_hidden),
"hidden files should not be visible by default"
);
}
#[test]
fn toggle_hidden_reveals_hidden_files() {
let dir = make_test_dir();
let mut state = AppState::new(dir.path().to_owned()).unwrap();
state.toggle_hidden().unwrap();
assert!(
state.visible_entries().any(|e| e.is_hidden),
"hidden files should be visible after toggle"
);
}
#[test]
fn move_down_clamps_at_bottom() {
let dir = make_test_dir();
let mut state = AppState::new(dir.path().to_owned()).unwrap();
let count = state.visible_count();
for _ in 0..count + 5 {
state.move_down();
}
assert_eq!(state.selected, count - 1);
}
#[test]
fn move_up_clamps_at_top() {
let dir = make_test_dir();
let mut state = AppState::new(dir.path().to_owned()).unwrap();
state.move_up(); assert_eq!(state.selected, 0);
}
#[test]
fn jump_top_bottom() {
let dir = make_test_dir();
let mut state = AppState::new(dir.path().to_owned()).unwrap();
state.jump_bottom();
let count = state.visible_count();
assert_eq!(state.selected, count - 1);
state.jump_top();
assert_eq!(state.selected, 0);
}
#[test]
fn dirty_set_on_mutation() {
let dir = make_test_dir();
let mut state = AppState::new(dir.path().to_owned()).unwrap();
state.dirty = false; state.move_down();
assert!(state.dirty);
}
#[test]
fn toggling_hidden_keeps_the_selection_on_the_same_entry() {
let dir = make_test_dir();
let mut state = AppState::new(dir.path().to_owned()).unwrap();
state.jump_bottom(); let anchor = state.selected_entry().map(|e| e.path.clone()).unwrap();
assert!(
anchor.ends_with("a_file.txt"),
"fixture changed: {anchor:?}"
);
state.toggle_hidden().unwrap();
assert_eq!(
state.selected_entry().map(|e| e.path.clone()),
Some(anchor.clone()),
"revealing hidden files must not move the selection"
);
assert_eq!(
state.selected, 3,
"the row it sits on has to have moved, or the test proves nothing"
);
state.toggle_hidden().unwrap();
assert_eq!(
state.selected_entry().map(|e| e.path.clone()),
Some(anchor),
"hiding them again must put it back"
);
}
#[test]
fn a_refresh_keeps_the_selection_when_an_entry_appears_above_it() {
let dir = make_test_dir();
let mut state = AppState::new(dir.path().to_owned()).unwrap();
state.jump_bottom();
let anchor = state.selected_entry().map(|e| e.path.clone()).unwrap();
fs::write(dir.path().join("0_first.txt"), b"").unwrap();
state.refresh().unwrap();
assert_eq!(state.selected_entry().map(|e| e.path.clone()), Some(anchor));
}
#[test]
fn returning_to_a_directory_reselects_the_same_entry() {
let dir = make_test_dir();
let mut state = AppState::new(dir.path().to_owned()).unwrap();
state.jump_bottom();
let anchor = state.selected_entry().map(|e| e.path.clone()).unwrap();
let child = state.cwd.join("alpha_dir");
state.enter_dir(child).unwrap();
fs::write(dir.path().join("0_first.txt"), b"").unwrap();
state.go_parent().unwrap();
assert_eq!(state.selected_entry().map(|e| e.path.clone()), Some(anchor));
}
#[test]
fn an_outcome_is_not_an_error() {
let dir = make_test_dir();
let mut state = AppState::new(dir.path().to_owned()).unwrap();
state.notify("bookmark added: work");
assert_eq!(
state.notice.as_ref().map(|n| n.level),
Some(NoticeLevel::Info)
);
assert!(
state.error_text().is_none(),
"an info notice must not read as a failure"
);
state.set_error("mkdir: destination already exists");
assert_eq!(
state.notice.as_ref().map(|n| n.level),
Some(NoticeLevel::Error)
);
assert_eq!(
state.error_text(),
Some("mkdir: destination already exists")
);
}
#[test]
fn clearing_a_notice_reports_whether_there_was_one() {
let dir = make_test_dir();
let mut state = AppState::new(dir.path().to_owned()).unwrap();
assert!(!state.clear_notice(), "nothing to clear on a fresh state");
state.notify("yanked: src/main.rs");
state.dirty = false; assert!(state.clear_notice(), "the notice was there to clear");
assert!(state.notice.is_none());
assert!(state.dirty, "dismissing a notice has to be drawn");
assert!(!state.clear_notice(), "and it is gone for good");
}
}