ised 0.1.0

An interactive tool for find-and-replace across many files
Documentation
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
use std::{fs, io};
use tui::backend::Backend;
use tui::Terminal;

use crate::config::find_and_load_config;
use crate::ui;
use crate::utils::apply_substitution_partial;

#[derive(PartialEq, Eq, Clone, Copy)]
pub enum Focus {
    FileList,
    FilePathFilter,
    DiffView,
    From,
    To,
}

pub enum ConfirmState {
    None,
    Confirming(String),
    ConfirmingAll(Vec<String>),
}

pub struct App {
    pub files: Vec<String>,
    pub selected: usize,
    pub offset: usize,
    pub filter_input: String,
    pub from_input: String,
    pub to_input: String,
    pub focus: Focus,
    pub diff_scroll: usize,
    pub confirm: ConfirmState,
}

impl Default for App {
    fn default() -> Self {
        Self::new()
    }
}

impl App {
    pub fn new() -> Self {
        let files: Vec<String> = walkdir::WalkDir::new(".")
            .into_iter()
            .filter_map(|e| e.ok())
            .filter(|e| e.file_type().is_file())
            .map(|e| e.path().display().to_string())
            .collect();

        let config = find_and_load_config();

        let filter_input = config
            .as_ref()
            .and_then(|c| c.files.as_ref())
            .and_then(|f| f.glob_filter.as_ref())
            .map(|patterns| patterns.join(","))
            .unwrap_or_default();

        App {
            files,
            selected: 0,
            offset: 0,
            filter_input,
            from_input: String::new(),
            to_input: String::new(),
            focus: Focus::FileList,
            diff_scroll: 0,
            confirm: ConfirmState::None,
        }
    }

    pub fn run<B: Backend>(&mut self, terminal: &mut Terminal<B>) -> io::Result<()> {
        loop {
            let filtered_files = self.filter_files();

            if self.selected >= filtered_files.len() {
                self.selected = 0;
                self.offset = 0;
            }

            terminal.draw(|f| ui::draw(f, self, &filtered_files))?;

            if event::poll(std::time::Duration::from_millis(200))? {
                if let Event::Key(key) = event::read()? {
                    if self.handle_key_event(key, &filtered_files)? {
                        break;
                    }
                }
            }
        }
        Ok(())
    }

    fn filter_files(&self) -> Vec<String> {
        use globset::{Glob, GlobSetBuilder};

        if self.filter_input.trim().is_empty() && self.from_input.trim().is_empty() {
            return self.files.clone();
        }

        let patterns: Vec<_> = self
            .filter_input
            .split(',')
            .map(str::trim)
            .filter(|p| !p.is_empty())
            .collect();

        let mut include_builder = GlobSetBuilder::new();
        let mut exclude_builder = GlobSetBuilder::new();
        let mut has_include = false;

        for pat in &patterns {
            if let Some(stripped) = pat.strip_prefix('!') {
                if let Ok(glob) = Glob::new(stripped) {
                    exclude_builder.add(glob);
                }
            } else {
                has_include = true;
                if let Ok(glob) = Glob::new(pat) {
                    include_builder.add(glob);
                }
            }
        }

        let include_set = include_builder.build().ok();
        let exclude_set = exclude_builder.build().ok();

        let from_re = regex::Regex::new(&self.from_input).ok();

        self.files
            .iter()
            .filter(|f| {
                let included = if has_include {
                    include_set
                        .as_ref()
                        .map(|set| set.is_match(f))
                        .unwrap_or(false)
                } else {
                    true
                };

                let excluded = exclude_set
                    .as_ref()
                    .map(|set| set.is_match(f))
                    .unwrap_or(false);

                let matches_from = if let Some(re) = &from_re {
                    std::fs::read_to_string(f)
                        .map(|content| re.is_match(&content))
                        .unwrap_or(false)
                } else {
                    true
                };

                included && !excluded && matches_from
            })
            .cloned()
            .collect()
    }

    fn handle_key_event(&mut self, key: KeyEvent, filtered_files: &[String]) -> io::Result<bool> {
        match key {
            KeyEvent {
                code: KeyCode::Char('c'),
                modifiers: KeyModifiers::CONTROL,
                ..
            } => return Ok(true),

            KeyEvent {
                code: KeyCode::Char('l'),
                modifiers: KeyModifiers::CONTROL,
                ..
            } => {
                self.focus = Focus::FileList;
            }

            KeyEvent {
                code: KeyCode::Char('g'),
                modifiers: KeyModifiers::CONTROL,
                ..
            } => {
                self.focus = Focus::FilePathFilter;
            }

            KeyEvent {
                code: KeyCode::Char('d'),
                modifiers: KeyModifiers::CONTROL,
                ..
            } => {
                self.focus = Focus::DiffView;
            }

            KeyEvent {
                code: KeyCode::Char('f'),
                modifiers: KeyModifiers::CONTROL,
                ..
            } => {
                self.focus = Focus::From;
            }

            KeyEvent {
                code: KeyCode::Char('t'),
                modifiers: KeyModifiers::CONTROL,
                ..
            } => {
                self.focus = Focus::To;
            }

            KeyEvent {
                code: KeyCode::Tab, ..
            } => {
                self.focus = match self.focus {
                    Focus::FileList => Focus::FilePathFilter,
                    Focus::FilePathFilter => Focus::DiffView,
                    Focus::DiffView => Focus::From,
                    Focus::From => Focus::To,
                    Focus::To => Focus::FileList,
                };
            }

            KeyEvent {
                code: KeyCode::Char('a'),
                modifiers: KeyModifiers::CONTROL,
                ..
            } => {
                if self.focus == Focus::FileList {
                    self.confirm = ConfirmState::ConfirmingAll(filtered_files.to_vec());
                }
            }

            KeyEvent {
                code: KeyCode::Enter,
                ..
            } => {
                if self.focus == Focus::FileList {
                    if let Some(file) = filtered_files.get(self.selected) {
                        self.confirm = ConfirmState::Confirming(file.clone());
                    }
                }
            }

            KeyEvent {
                code: KeyCode::Char('y'),
                ..
            } => match &self.confirm {
                ConfirmState::Confirming(path) => {
                    self.apply_substitution(path)?;
                    self.confirm = ConfirmState::None;
                }
                ConfirmState::ConfirmingAll(paths) => {
                    for path in paths {
                        let _ = self.apply_substitution(path);
                    }
                    self.confirm = ConfirmState::None;
                }
                ConfirmState::None => self.push_input('y'),
            },

            KeyEvent {
                code: KeyCode::Char('n'),
                ..
            } => {
                if !matches!(self.confirm, ConfirmState::None) {
                    self.confirm = ConfirmState::None;
                } else {
                    self.push_input('n');
                }
            }

            KeyEvent {
                code: KeyCode::Esc, ..
            } => {
                self.confirm = ConfirmState::None;
            }

            KeyEvent {
                code: KeyCode::Up, ..
            } => match self.focus {
                Focus::FileList => {
                    if self.selected > 0 {
                        self.selected -= 1;
                    }
                }
                Focus::DiffView => {
                    self.diff_scroll = self.diff_scroll.saturating_sub(1);
                }
                _ => {}
            },

            KeyEvent {
                code: KeyCode::Down,
                ..
            } => match self.focus {
                Focus::FileList => {
                    if self.selected + 1 < filtered_files.len() {
                        self.selected += 1;
                    }
                }
                Focus::DiffView => {
                    self.diff_scroll += 1;
                }
                _ => {}
            },

            KeyEvent {
                code: KeyCode::Char(c),
                ..
            } => match c {
                'j' => match self.focus {
                    Focus::FileList => {
                        if self.selected + 1 < filtered_files.len() {
                            self.selected += 1;
                        }
                    }
                    Focus::DiffView => self.diff_scroll += 1,
                    _ => self.push_input('j'),
                },
                'k' => match self.focus {
                    Focus::FileList => self.selected = self.selected.saturating_sub(1),
                    Focus::DiffView => self.diff_scroll = self.diff_scroll.saturating_sub(1),
                    _ => self.push_input('k'),
                },
                _ => self.push_input(c),
            },

            KeyEvent {
                code: KeyCode::Backspace,
                ..
            } => match self.focus {
                Focus::FilePathFilter => {
                    self.filter_input.pop();
                    self.selected = 0;
                    self.offset = 0;
                }
                Focus::From => {
                    self.from_input.pop();
                }
                Focus::To => {
                    self.to_input.pop();
                }
                _ => {}
            },

            _ => {}
        }
        Ok(false)
    }

    fn push_input(&mut self, c: char) {
        match self.focus {
            Focus::FilePathFilter => self.filter_input.push(c),
            Focus::From => self.from_input.push(c),
            Focus::To => self.to_input.push(c),
            _ => {}
        }
    }

    fn apply_substitution(&self, path: &str) -> io::Result<()> {
        let content = fs::read_to_string(path)?;
        let replaced = apply_substitution_partial(&content, &self.from_input, &self.to_input);
        fs::write(path, replaced)?;
        Ok(())
    }
}