use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use crossterm::event::KeyEvent;
use ratatui::prelude::Rect;
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc;
use tracing::{debug, info};
use crate::{
action::Action,
cli::DiffRange,
components::{Component, diff_view::DiffView, toast::Toast},
config::Config,
state::App as DiffApp,
tui::{Event, Tui},
};
pub struct App {
config: Config,
tick_rate: f64,
frame_rate: f64,
diff_view: DiffView,
toast: Toast,
should_quit: bool,
should_suspend: bool,
mode: Mode,
last_tick_key_events: Vec<KeyEvent>,
action_tx: mpsc::UnboundedSender<Action>,
action_rx: mpsc::UnboundedReceiver<Action>,
files_changed: Arc<AtomicBool>,
_watcher: Option<notify::RecommendedWatcher>,
}
#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Mode {
#[default]
Home,
}
impl App {
pub fn new(
tick_rate: f64,
frame_rate: f64,
state: DiffApp,
config: Config,
) -> color_eyre::Result<Self> {
let (action_tx, action_rx) = mpsc::unbounded_channel();
let files_changed = Arc::new(AtomicBool::new(false));
let watcher = setup_file_watcher(&state.range, Arc::clone(&files_changed));
let toast = Toast::new(state.theme_name, state.color_overrides.clone());
Ok(Self {
tick_rate,
frame_rate,
diff_view: DiffView::new(state),
toast,
should_quit: false,
should_suspend: false,
config,
mode: Mode::Home,
last_tick_key_events: Vec::new(),
action_tx,
action_rx,
files_changed,
_watcher: watcher,
})
}
pub fn into_diff_state(self) -> DiffApp {
self.diff_view.state
}
pub async fn run(&mut self) -> color_eyre::Result<()> {
let mut tui = Tui::new()?
.tick_rate(self.tick_rate)
.frame_rate(self.frame_rate);
tui.enter()?;
self.diff_view
.register_action_handler(self.action_tx.clone())?;
self.diff_view
.register_config_handler(self.config.clone())?;
self.diff_view.init(tui.size()?)?;
self.toast.register_action_handler(self.action_tx.clone())?;
self.toast.init(tui.size()?)?;
let action_tx = self.action_tx.clone();
loop {
self.handle_events(&mut tui).await?;
self.handle_actions(&mut tui)?;
if self.should_suspend {
tui.suspend()?;
action_tx.send(Action::Resume)?;
action_tx.send(Action::ClearScreen)?;
tui.enter()?;
} else if self.should_quit {
tui.stop()?;
break;
}
}
tui.exit()?;
Ok(())
}
async fn handle_events(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
let Some(event) = tui.next_event().await else {
return Ok(());
};
let action_tx = self.action_tx.clone();
match event {
Event::Quit => action_tx.send(Action::Quit)?,
Event::Tick => {
action_tx.send(Action::Tick)?;
if self.files_changed.swap(false, Ordering::Relaxed) {
action_tx.send(Action::Flash(
"Files changed - press R to reload".to_string(),
))?;
}
}
Event::Render => action_tx.send(Action::Render)?,
Event::Resize(x, y) => action_tx.send(Action::Resize(x, y))?,
Event::Key(key) => self.handle_key_event(key)?,
_ => {}
}
if let Some(action) = self.diff_view.handle_events(Some(event.clone()))? {
action_tx.send(action)?;
}
if let Some(action) = self.toast.handle_events(Some(event))? {
action_tx.send(action)?;
}
Ok(())
}
fn handle_key_event(&mut self, key: KeyEvent) -> color_eyre::Result<()> {
let action_tx = self.action_tx.clone();
let Some(keymap) = self.config.keybindings.0.get(&self.mode) else {
return Ok(());
};
match keymap.get(&vec![key]) {
Some(action) => {
info!("Got action: {action:?}");
action_tx.send(action.clone())?;
}
_ => {
self.last_tick_key_events.push(key);
if let Some(action) = keymap.get(&self.last_tick_key_events) {
info!("Got action: {action:?}");
action_tx.send(action.clone())?;
}
}
}
Ok(())
}
fn handle_actions(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
while let Ok(action) = self.action_rx.try_recv() {
if action != Action::Tick && action != Action::Render {
debug!("{action:?}");
}
match action {
Action::Tick => {
self.last_tick_key_events.drain(..);
}
Action::Quit => self.should_quit = true,
Action::Suspend => self.should_suspend = true,
Action::Resume => self.should_suspend = false,
Action::ClearScreen => tui.terminal.clear()?,
Action::Resize(w, h) => self.handle_resize(tui, w, h)?,
Action::Render => self.render(tui)?,
Action::Reload => self.reload_diff(),
Action::OpenEditor => self.open_editor(tui)?,
_ => {}
}
if let Some(action) = self.diff_view.update(action.clone())? {
self.action_tx.send(action)?;
}
if let Some(action) = self.toast.update(action.clone())? {
self.action_tx.send(action)?;
}
}
Ok(())
}
fn handle_resize(&mut self, tui: &mut Tui, w: u16, h: u16) -> color_eyre::Result<()> {
tui.resize(Rect::new(0, 0, w, h))?;
self.render(tui)?;
Ok(())
}
fn render(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
self.toast.set_theme(
self.diff_view.state.theme_name,
self.diff_view.state.color_overrides.clone(),
);
tui.draw(|frame| {
if let Err(err) = self.diff_view.draw(frame, frame.area()) {
let _ = self
.action_tx
.send(Action::Error(format!("Failed to draw: {:?}", err)));
}
if let Err(err) = self.toast.draw(frame, frame.area()) {
let _ = self
.action_tx
.send(Action::Error(format!("Failed to draw: {:?}", err)));
}
})?;
Ok(())
}
fn reload_diff(&mut self) {
let state = &mut self.diff_view.state;
match crate::git::load_diff_simple(&state.range) {
Ok(mut files) => {
crate::highlight::highlight_files(&mut files);
state.diff_fingerprint = crate::storage::diff_fingerprint(&files);
state.files = files;
state.selected = state.selected.min(state.files.len().saturating_sub(1));
state.cursor_line = state.first_navigable_line();
state.diff_scroll = 0;
state.comment_focus = None;
state.set_flash("Reloaded".to_string());
}
Err(e) => {
state.set_flash(format!("Reload failed: {e}"));
}
}
}
fn open_editor(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
let path = self.diff_view.state.current_path();
let lineno = self.diff_view.state.current_lineno();
if path.is_empty() {
return Ok(());
}
let editor = std::env::var("SHUIRE_EDITOR")
.or_else(|_| std::env::var("EDITOR"))
.unwrap_or_else(|_| "vi".to_string());
tui.exit()?;
let _ = if editor.contains("code") || editor.contains("cursor") {
std::process::Command::new(&editor)
.arg("--goto")
.arg(format!("{path}:{lineno}"))
.status()
} else {
std::process::Command::new(&editor)
.arg(format!("+{lineno}"))
.arg(&path)
.status()
};
tui.enter()?;
self.action_tx.send(Action::ClearScreen)?;
Ok(())
}
}
fn setup_file_watcher(
range: &DiffRange,
changed_flag: Arc<AtomicBool>,
) -> Option<notify::RecommendedWatcher> {
use notify::{RecursiveMode, Watcher};
if !matches!(
range,
DiffRange::Working
| DiffRange::Staged
| DiffRange::StagedAgainst { .. }
| DiffRange::Uncommitted
| DiffRange::UncommittedAgainst { .. }
) {
return None;
}
let cwd = std::env::current_dir().ok()?;
const IGNORED_DIRS: &[&str] = &[".git", "target", "node_modules", "dist", "build", ".next"];
let mut watcher =
notify::recommended_watcher(move |res: Result<notify::Event, notify::Error>| {
if let Ok(ev) = res {
let dominated_by_ignored = ev.paths.iter().all(|p| {
p.components()
.any(|c| IGNORED_DIRS.contains(&c.as_os_str().to_str().unwrap_or("")))
});
if !dominated_by_ignored {
changed_flag.store(true, Ordering::Relaxed);
}
}
})
.ok()?;
watcher.watch(&cwd, RecursiveMode::Recursive).ok()?;
Some(watcher)
}