use std::collections::HashSet;
use std::io;
use std::path::{Path, PathBuf};
use anyhow::Result;
use crossterm::event::{Event as TerminalEvent, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::DefaultTerminal;
use crate::clipboard;
use crate::command_line::{CommandLine, Outcome};
use crate::commands::{Command, REGISTRY};
use crate::config::{Config, Toggles};
use crate::editor;
use crate::events::{Event, Events};
use crate::git::{self, GitInfo, Ignores};
use crate::search::{self, Search};
use crate::state::State;
use crate::tree::scan::{Entry, Scanner};
use crate::tree::watch::Watcher;
use crate::tree::{Filter, NodeId, ROOT, Tree};
use crate::ui;
const EXPAND_ALL_LIMIT: usize = 20_000;
const SEARCH_LOAD_LIMIT: usize = 50_000;
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Normal,
Command,
Search,
Help,
}
pub struct App {
pub root: PathBuf,
pub tree: Tree,
pub config: Config,
pub mode: Mode,
pub command_line: CommandLine,
pub search: Search,
search_return: Option<NodeId>,
pub help_scroll: usize,
pub scroll: usize,
pub message: Option<String>,
pub git: GitInfo,
pub git_pending: bool,
pub toggles: Toggles,
pending_expand: Vec<PathBuf>,
expanding: Vec<PathBuf>,
pending_select: Option<PathBuf>,
events: Events,
scanner: Scanner,
ignores: Ignores,
watcher: Watcher,
viewport_height: usize,
pending_open: Option<PathBuf>,
quit: bool,
}
impl App {
pub fn new(root: PathBuf, config: Config) -> Self {
let events = Events::new();
let scanner = Scanner::new(events.sender());
let watcher = Watcher::new(config.watch, events.sender());
let ignores = Ignores::open(&root);
Self {
tree: Tree::new(root.clone()),
toggles: config.toggles,
root,
config,
mode: Mode::Normal,
command_line: CommandLine::new(),
search: Search::default(),
search_return: None,
help_scroll: 0,
scroll: 0,
message: None,
git: GitInfo::none(),
git_pending: false,
pending_expand: Vec::new(),
expanding: Vec::new(),
pending_select: None,
ignores,
watcher,
events,
scanner,
viewport_height: 1,
pending_open: None,
quit: false,
}
}
pub fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<()> {
if let Some(git_dir) = git::git_dir(&self.root) {
self.watcher.watch(&git_dir);
}
self.restore();
self.request(ROOT);
self.apply_filter();
self.refresh_git();
while !self.quit {
if self.tree.needs_ignore_classification() {
self.ignores.classify(&mut self.tree);
}
if self.mode == Mode::Search {
self.load_unread_folders();
}
self.tree.refresh_rows();
self.viewport_height = usize::from(terminal.size()?.height)
.saturating_sub(2)
.max(1);
self.clamp_scroll();
terminal.draw(|frame| ui::render(self, frame))?;
match self.events.next()? {
Event::Input(TerminalEvent::Key(key)) => self.handle_key(key),
Event::Input(_) => {}
Event::ScanDone { path, entries } => self.scan_done(path, entries),
Event::GitDone(result) => self.git_done(*result),
Event::FsChange(paths) => self.fs_change(paths),
}
if let Some(path) = self.pending_open.take() {
self.save();
self.events.suspend();
let opened = editor::open(terminal, &path, &self.config.editor);
self.events.resume();
if let Err(err) = opened {
self.message = Some(format!("{err:#}"));
}
self.refresh_git();
}
}
self.save();
Ok(())
}
fn restore(&mut self) {
let Some(state) = State::load(&self.root) else {
return;
};
self.toggles = state.toggles;
self.pending_expand = state
.expanded
.iter()
.map(|relative| self.root.join(relative))
.collect();
self.pending_select = state.selected.map(|relative| self.root.join(relative));
}
fn apply_restored(&mut self) {
let mut waiting = Vec::new();
for path in std::mem::take(&mut self.pending_expand) {
match self.tree.find(&path) {
Some(id) => self.expand(id),
None => waiting.push(path),
}
}
self.pending_expand = waiting;
if let Some(path) = self.pending_select.clone()
&& let Some(id) = self.tree.find(&path)
{
self.tree.select(id);
self.pending_select = None;
}
}
fn save(&mut self) {
let expanded = self.expanded_paths();
let selected = self
.relative(&self.tree.node(self.tree.selected()).path)
.filter(|path| !path.as_os_str().is_empty());
let state = State::new(self.root.clone(), selected, expanded, self.toggles);
if let Err(err) = state.save() {
self.message = Some(format!("could not save state: {err:#}"));
}
}
fn expanded_paths(&self) -> Vec<PathBuf> {
let mut paths: Vec<PathBuf> = self
.tree
.iter()
.filter(|(id, node)| *id != ROOT && node.expanded && node.kind.is_dir())
.filter_map(|(_, node)| self.relative(&node.path))
.collect();
paths.sort();
paths
}
fn relative(&self, path: &Path) -> Option<PathBuf> {
path.strip_prefix(&self.root).ok().map(Path::to_path_buf)
}
fn handle_key(&mut self, key: KeyEvent) {
if key.kind != KeyEventKind::Press {
return;
}
match self.mode {
Mode::Normal => self.handle_normal_key(key),
Mode::Command => self.handle_command_key(key),
Mode::Search => self.handle_search_key(key),
Mode::Help => self.handle_help_key(key),
}
}
fn handle_normal_key(&mut self, key: KeyEvent) {
if let Some(command) = self.config.keys.get(key) {
self.dispatch(command);
}
}
fn handle_command_key(&mut self, key: KeyEvent) {
if let Some(command) = self.prompt_command(key) {
self.dispatch(command);
return;
}
let outcome = self.command_line.handle_key(key);
self.command_line_outcome(outcome);
}
fn command_line_outcome(&mut self, outcome: Outcome) {
match outcome {
Outcome::Continue => {}
Outcome::Cancel => self.dispatch(Command::Dismiss),
Outcome::Error(message) => self.message = Some(message),
Outcome::Run(command) => {
self.mode = Mode::Normal;
self.dispatch(command);
}
}
}
fn handle_search_key(&mut self, key: KeyEvent) {
if let Some(command) = self.prompt_command(key) {
self.dispatch(command);
return;
}
match self.search.handle_key(key) {
search::Outcome::Continue => {}
search::Outcome::Edited => self.requery(),
search::Outcome::Accept => self.dispatch(Command::Select),
search::Outcome::Cancel => self.dispatch(Command::Dismiss),
}
}
fn handle_help_key(&mut self, key: KeyEvent) {
if let Some(command) = self.config.keys.get(key).filter(acts_on_top) {
self.dispatch(command);
}
}
fn prompt_command(&self, key: KeyEvent) -> Option<Command> {
let typed = matches!(key.code, KeyCode::Char(_))
&& key.modifiers.difference(KeyModifiers::SHIFT).is_empty();
match typed {
true => None,
false => self.config.keys.get(key).filter(acts_on_top),
}
}
pub fn dispatch(&mut self, command: Command) {
self.message = None;
match command {
Command::MoveDown => self.move_by(1),
Command::MoveUp => self.move_by(-1),
Command::MoveNextSibling => self.tree.move_next_sibling(),
Command::MovePrevSibling => self.tree.move_prev_sibling(),
Command::MoveParent => self.tree.move_parent(),
Command::MoveFirst => self.tree.select_row(0),
Command::MoveLast => self.tree.move_last(),
Command::JumpDown => self.move_cursor_by(self.config.jump_lines as isize),
Command::JumpUp => self.move_cursor_by(-(self.config.jump_lines as isize)),
Command::HalfPageDown => self.move_cursor_by((self.viewport_height / 2) as isize),
Command::HalfPageUp => self.move_cursor_by(-((self.viewport_height / 2) as isize)),
Command::CenterCursor => self.center_cursor(),
Command::Select => self.select(),
Command::Open => self.open_selected(),
Command::ExpandAll => self.expand_all(),
Command::CollapseAll => self.collapse_all(),
Command::YankPath => self.yank(YankKind::Absolute),
Command::YankRelativePath => self.yank(YankKind::Relative),
Command::ToggleHidden => self.toggle(Toggle::Hidden),
Command::ToggleGitignored => self.toggle(Toggle::Gitignored),
Command::ToggleChangedOnly => self.toggle(Toggle::ChangedOnly),
Command::Refresh => self.refresh(),
Command::OpenCommandBar => self.open_command_bar(),
Command::Search => self.open_search(),
Command::Dismiss => self.dismiss(),
Command::Help => self.show_help(),
Command::Quit => self.quit = true,
}
}
fn move_by(&mut self, delta: isize) {
match self.mode {
Mode::Help => self.scroll_help(delta),
Mode::Command => self.command_line.highlight(delta),
Mode::Search => self.tree.move_to_match(delta),
Mode::Normal => self.tree.move_by(delta),
}
}
fn scroll_help(&mut self, delta: isize) {
self.help_scroll = self
.help_scroll
.saturating_add_signed(delta)
.min(REGISTRY.len().saturating_sub(1));
}
fn move_cursor_by(&mut self, delta: isize) {
let row = self.tree.selected_row().saturating_add_signed(delta);
self.tree
.select_row(row.min(self.tree.rows().len().saturating_sub(1)));
}
fn center_cursor(&mut self) {
self.scroll = self
.tree
.selected_row()
.saturating_sub(self.viewport_height / 2);
}
fn clamp_scroll(&mut self) {
let rows = self.tree.rows().len();
let cursor = self.tree.selected_row();
let scrolloff = self
.config
.scrolloff
.min(self.viewport_height.saturating_sub(1) / 2);
if cursor < self.scroll + scrolloff {
self.scroll = cursor.saturating_sub(scrolloff);
}
if cursor + scrolloff >= self.scroll + self.viewport_height {
self.scroll = cursor + scrolloff + 1 - self.viewport_height;
}
self.scroll = self.scroll.min(rows.saturating_sub(self.viewport_height));
}
fn select(&mut self) {
match self.mode {
Mode::Command => {
let outcome = self.command_line.run();
self.command_line_outcome(outcome);
}
Mode::Search => self.select_match(),
Mode::Help => {}
Mode::Normal => self.select_entry(),
}
}
fn select_entry(&mut self) {
let id = self.tree.selected();
let node = self.tree.node(id);
if !node.kind.is_dir() {
self.open_selected();
return;
}
match node.expanded {
true => self.collapse(id),
false => self.expand(id),
}
}
fn open_selected(&mut self) {
let node = self.tree.node(self.tree.selected());
if node.kind.is_dir() {
self.message = Some("not a file".to_owned());
return;
}
self.pending_open = Some(node.path.clone());
}
fn expand_all(&mut self) {
let Some(id) = self.focused_folder() else {
return;
};
self.expanding.push(self.tree.node(id).path.clone());
self.expand_subtree(id);
}
fn expand_subtree(&mut self, id: NodeId) {
let mut stack = vec![id];
while let Some(current) = stack.pop() {
if self.tree.node_count() > EXPAND_ALL_LIMIT {
self.expanding.clear();
self.message = Some(format!("stopped expanding at {EXPAND_ALL_LIMIT} entries"));
return;
}
self.expand(current);
let children = self.tree.node(current).children.clone().unwrap_or_default();
let subdirectories = children
.into_iter()
.filter(|child| self.tree.node(*child).kind.is_dir());
stack.extend(subdirectories);
}
}
fn is_expanding(&self, path: &Path) -> bool {
self.expanding.iter().any(|root| path.starts_with(root))
}
fn collapse_all(&mut self) {
let Some(id) = self.focused_folder() else {
return;
};
self.expanding.clear();
self.tree.collapse_subtree(id);
self.tree.select(id);
}
fn focused_folder(&self) -> Option<NodeId> {
let id = self.tree.selected();
let node = self.tree.node(id);
match node.kind.is_dir() {
true => Some(id),
false => node.parent,
}
}
fn yank(&mut self, kind: YankKind) {
let path = self.tree.node(self.tree.selected()).path.clone();
let text = match kind {
YankKind::Absolute => path.to_string_lossy().into_owned(),
YankKind::Relative => self
.relative(&path)
.unwrap_or_else(|| path.clone())
.to_string_lossy()
.into_owned(),
};
match clipboard::copy(&text) {
Ok(()) => self.message = Some(format!("yanked {text}")),
Err(err) => self.message = Some(format!("could not yank: {err}")),
}
}
fn toggle(&mut self, toggle: Toggle) {
if toggle.needs_git() && !self.git.is_repo() {
self.message = Some("not a git repository".to_owned());
return;
}
match toggle {
Toggle::Hidden => self.toggles.show_hidden = !self.toggles.show_hidden,
Toggle::Gitignored => self.toggles.show_gitignored = !self.toggles.show_gitignored,
Toggle::ChangedOnly => self.toggles.changed_only = !self.toggles.changed_only,
}
self.apply_filter();
}
fn apply_filter(&mut self) {
self.tree.set_filter(Filter {
show_hidden: self.toggles.show_hidden,
show_gitignored: self.toggles.show_gitignored,
changed_only: self.toggles.changed_only,
});
if self.tree.is_searching() {
self.refresh_matches();
}
if self.toggles.changed_only {
self.load_changed_directories();
}
}
fn load_changed_directories(&mut self) {
let pending: Vec<PathBuf> = self
.tree
.iter()
.filter(|(_, node)| node.changed && node.kind.is_dir() && !node.is_loaded())
.map(|(_, node)| node.path.clone())
.collect();
for path in pending {
self.scanner.request(path, self.config.sort_order);
}
}
fn refresh(&mut self) {
let loaded: Vec<PathBuf> = self
.tree
.iter()
.filter(|(_, node)| node.kind.is_dir() && node.is_loaded())
.map(|(_, node)| node.path.clone())
.collect();
for path in loaded {
self.scanner.request(path, self.config.sort_order);
}
self.refresh_git();
}
fn open_command_bar(&mut self) {
self.command_line.open();
self.mode = Mode::Command;
}
fn open_search(&mut self) {
self.search.clear();
self.search_return = Some(self.tree.selected());
self.mode = Mode::Search;
self.tree.rewind_unloaded();
self.requery();
}
fn requery(&mut self) {
self.refresh_matches();
self.tree.refresh_rows();
self.tree.select_best_match();
}
fn refresh_matches(&mut self) {
let matches = self.search.matches(&self.tree, &self.root);
self.tree.set_search(matches);
}
fn load_unread_folders(&mut self) {
if self.tree.node_count() > SEARCH_LOAD_LIMIT {
self.message = Some(format!("searching the first {SEARCH_LOAD_LIMIT} entries"));
return;
}
for id in self.tree.unloaded_directories() {
self.request(id);
}
}
fn select_match(&mut self) {
if self.tree.match_count() == 0 {
self.cancel_search();
return;
}
let selected = self.tree.selected();
self.leave_search();
self.reveal(selected);
}
fn cancel_search(&mut self) {
let restore = self.search_return;
self.leave_search();
if let Some(id) = restore {
self.tree.select(id);
}
}
fn leave_search(&mut self) {
self.mode = Mode::Normal;
self.search_return = None;
self.search.clear();
self.tree.set_search(None);
}
fn reveal(&mut self, id: NodeId) {
let mut current = self.tree.node(id).parent;
while let Some(parent) = current {
self.expand(parent);
current = self.tree.node(parent).parent;
}
self.tree.select(id);
self.tree.refresh_rows();
self.center_cursor();
}
fn dismiss(&mut self) {
match self.mode {
Mode::Search => self.cancel_search(),
Mode::Command | Mode::Help => self.mode = Mode::Normal,
Mode::Normal => {}
}
}
fn show_help(&mut self) {
self.help_scroll = 0;
self.mode = Mode::Help;
}
fn expand(&mut self, id: NodeId) {
self.tree.set_expanded(id, true);
match self.tree.node(id).is_loaded() {
true => self.prefetch(id),
false => self.request(id),
}
}
fn collapse(&mut self, id: NodeId) {
self.expanding.clear();
self.tree.set_expanded(id, false);
}
fn request(&mut self, id: NodeId) {
let path = self.tree.node(id).path.clone();
self.scanner.request(path, self.config.sort_order);
}
fn prefetch(&mut self, id: NodeId) {
let Some(children) = self.tree.node(id).children.clone() else {
return;
};
for child in children {
let node = self.tree.node(child);
if node.kind.is_dir() && !node.is_loaded() {
let path = node.path.clone();
self.scanner.request(path, self.config.sort_order);
}
}
}
fn scan_done(&mut self, path: PathBuf, entries: io::Result<Vec<Entry>>) {
self.scanner.finished(&path);
let Some(id) = self.tree.find(&path) else {
return;
};
let loaded = self.tree.node(id).is_loaded();
match entries {
Err(err) => {
self.message = Some(format!("{}: {err}", path.display()));
if !loaded {
self.tree.graft(id, Vec::new());
}
}
Ok(entries) if loaded => {
for removed in self.tree.reconcile(id, entries) {
self.watcher.unwatch(&removed);
}
}
Ok(entries) => self.tree.graft(id, entries),
}
self.watcher.watch(&path);
if self.tree.is_open(id) {
self.prefetch(id);
}
self.mark_changed();
self.apply_restored();
if self.is_expanding(&path) {
self.expand_subtree(id);
}
if self.mode == Mode::Search && self.scanner.is_idle() {
self.refresh_matches();
}
}
fn fs_change(&mut self, paths: Vec<PathBuf>) {
let mut directories: HashSet<PathBuf> = HashSet::new();
let mut git_touched = false;
for path in paths {
if is_inside_git_dir(&path) {
git_touched = true;
continue;
}
let parents = [path.parent(), Some(path.as_path())];
for parent in parents.into_iter().flatten() {
if self
.tree
.find(parent)
.is_some_and(|id| self.tree.node(id).is_loaded())
{
directories.insert(parent.to_path_buf());
}
}
}
for path in directories {
self.scanner.request(path, self.config.sort_order);
}
if git_touched {
self.refresh_git();
}
}
fn refresh_git(&mut self) {
self.git_pending = true;
git::spawn(self.root.clone(), self.events.sender());
}
fn git_done(&mut self, result: Result<GitInfo>) {
self.git_pending = false;
match result {
Ok(info) => self.git = info,
Err(err) => self.message = Some(format!("git: {err:#}")),
}
if !self.git.is_repo() {
self.toggles.changed_only = false;
}
self.mark_all_changed();
self.apply_filter();
}
fn mark_changed(&mut self) {
let Self { tree, git, .. } = self;
tree.mark_changed(&mut |path| git.has_changes(path));
}
fn mark_all_changed(&mut self) {
let Self { tree, git, .. } = self;
tree.mark_all_changed(&mut |path| git.has_changes(path));
}
pub fn is_selected(&self, row: usize) -> bool {
row == self.tree.selected_row()
}
}
#[derive(Clone, Copy)]
enum Toggle {
Hidden,
Gitignored,
ChangedOnly,
}
impl Toggle {
fn needs_git(self) -> bool {
matches!(self, Toggle::Gitignored | Toggle::ChangedOnly)
}
}
fn acts_on_top(command: &Command) -> bool {
matches!(
command,
Command::MoveDown | Command::MoveUp | Command::Select | Command::Dismiss
)
}
fn is_inside_git_dir(path: &Path) -> bool {
path.components()
.any(|component| component.as_os_str() == ".git")
}
#[derive(Clone, Copy)]
enum YankKind {
Absolute,
Relative,
}