mod app;
mod error;
mod git;
mod input;
mod model;
mod output;
mod persistence;
mod syntax;
mod ui;
use std::io;
use std::time::Duration;
use crossterm::{
event::{
self, Event, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags,
PushKeyboardEnhancementFlags,
},
execute,
terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
supports_keyboard_enhancement,
},
};
use ratatui::{Terminal, backend::CrosstermBackend};
use app::App;
use input::{Action, map_key_to_action};
use output::export_to_clipboard;
use persistence::save_session;
fn main() -> anyhow::Result<()> {
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
let _ = execute!(io::stdout(), PopKeyboardEnhancementFlags);
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen);
original_hook(panic_info);
}));
let keyboard_enhancement_supported = matches!(supports_keyboard_enhancement(), Ok(true));
let mut app = match App::new() {
Ok(mut app) => {
app.supports_keyboard_enhancement = keyboard_enhancement_supported;
app
}
Err(e) => {
eprintln!("Error: {}", e);
eprintln!("\nMake sure you're in a git repository with uncommitted changes.");
std::process::exit(1);
}
};
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
if keyboard_enhancement_supported {
let _ = execute!(
stdout,
PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
);
}
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut pending_z = false;
let mut pending_d = false;
loop {
terminal.draw(|frame| {
ui::render(frame, &mut app);
})?;
if event::poll(Duration::from_millis(100))?
&& let Event::Key(key) = event::read()?
{
if pending_z {
pending_z = false;
if key.code == crossterm::event::KeyCode::Char('z') {
app.center_cursor();
continue;
}
}
if pending_d {
pending_d = false;
if key.code == crossterm::event::KeyCode::Char('d') {
if !app.delete_comment_at_cursor() {
app.set_message("No comment at cursor");
}
continue;
}
}
let action = map_key_to_action(key, app.input_mode);
match action {
Action::Quit => {
app.should_quit = true;
}
Action::CursorDown(n) => match app.focused_panel {
app::FocusedPanel::FileList => app.file_list_down(n),
app::FocusedPanel::Diff => app.cursor_down(n),
},
Action::CursorUp(n) => match app.focused_panel {
app::FocusedPanel::FileList => app.file_list_up(n),
app::FocusedPanel::Diff => app.cursor_up(n),
},
Action::HalfPageDown => app.scroll_down(15),
Action::HalfPageUp => app.scroll_up(15),
Action::PageDown => app.scroll_down(30),
Action::PageUp => app.scroll_up(30),
Action::ScrollLeft(n) => app.scroll_left(n),
Action::ScrollRight(n) => app.scroll_right(n),
Action::PendingZCommand => {
pending_z = true;
}
Action::PendingDCommand => {
pending_d = true;
}
Action::GoToTop => app.jump_to_file(0),
Action::GoToBottom => {
let last = app.file_count().saturating_sub(1);
app.jump_to_file(last);
}
Action::NextFile => app.next_file(),
Action::PrevFile => app.prev_file(),
Action::NextHunk => app.next_hunk(),
Action::PrevHunk => app.prev_hunk(),
Action::ToggleReviewed => app.toggle_reviewed(),
Action::ToggleDiffView => app.toggle_diff_view_mode(),
Action::ToggleFocus => {
app.focused_panel = match app.focused_panel {
app::FocusedPanel::FileList => app::FocusedPanel::Diff,
app::FocusedPanel::Diff => app::FocusedPanel::FileList,
};
}
Action::SelectFile => {
if app.focused_panel == app::FocusedPanel::FileList {
app.jump_to_file(app.file_list_state.selected);
}
}
Action::ToggleHelp => app.toggle_help(),
Action::EnterCommandMode => app.enter_command_mode(),
Action::ExitMode => {
if app.input_mode == app::InputMode::Command {
app.exit_command_mode();
} else if app.input_mode == app::InputMode::Comment {
app.exit_comment_mode();
}
}
Action::AddLineComment => {
let line = app.get_line_at_cursor();
if line.is_some() {
app.enter_comment_mode(false, line);
} else {
app.set_message("Move cursor to a diff line to add a line comment");
}
}
Action::AddFileComment => {
app.enter_comment_mode(true, None);
}
Action::EditComment => {
if !app.enter_edit_mode() {
app.set_message("No comment at cursor");
}
}
Action::InsertChar(c) => {
if app.input_mode == app::InputMode::Command {
app.command_buffer.push(c);
} else if app.input_mode == app::InputMode::Comment {
app.comment_buffer.insert(app.comment_cursor, c);
app.comment_cursor += 1;
}
}
Action::DeleteChar => {
if app.input_mode == app::InputMode::Command {
app.command_buffer.pop();
} else if app.input_mode == app::InputMode::Comment && app.comment_cursor > 0 {
app.comment_cursor -= 1;
app.comment_buffer.remove(app.comment_cursor);
}
}
Action::CycleCommentType => {
app.cycle_comment_type();
}
Action::TextCursorLeft => {
if app.comment_cursor > 0 {
app.comment_cursor -= 1;
}
}
Action::TextCursorRight => {
if app.comment_cursor < app.comment_buffer.len() {
app.comment_cursor += 1;
}
}
Action::DeleteWord => {
if app.input_mode == app::InputMode::Comment && app.comment_cursor > 0 {
while app.comment_cursor > 0
&& app
.comment_buffer
.chars()
.nth(app.comment_cursor - 1)
.map(|c| c.is_whitespace())
.unwrap_or(false)
{
app.comment_cursor -= 1;
app.comment_buffer.remove(app.comment_cursor);
}
while app.comment_cursor > 0
&& app
.comment_buffer
.chars()
.nth(app.comment_cursor - 1)
.map(|c| !c.is_whitespace())
.unwrap_or(false)
{
app.comment_cursor -= 1;
app.comment_buffer.remove(app.comment_cursor);
}
}
}
Action::ClearLine => {
if app.input_mode == app::InputMode::Comment {
app.comment_buffer.clear();
app.comment_cursor = 0;
}
}
Action::SubmitInput => {
if app.input_mode == app::InputMode::Command {
let cmd = app.command_buffer.trim().to_string();
match cmd.as_str() {
"q" | "quit" => app.should_quit = true,
"w" | "write" => match save_session(&app.session) {
Ok(path) => {
app.dirty = false;
app.set_message(format!("Saved to {}", path.display()));
}
Err(e) => {
app.set_error(format!("Save failed: {}", e));
}
},
"x" | "wq" => match save_session(&app.session) {
Ok(_) => {
app.dirty = false;
if app.session.has_comments() {
app.exit_command_mode();
app.enter_confirm_mode(app::ConfirmAction::CopyAndQuit);
continue;
} else {
app.should_quit = true;
}
}
Err(e) => {
app.set_error(format!("Save failed: {}", e));
}
},
"e" | "reload" => match app.reload_diff_files() {
Ok(count) => {
app.set_message(format!("Reloaded {} files", count));
}
Err(e) => {
app.set_error(format!("Reload failed: {}", e));
}
},
"clip" | "export" => {
match export_to_clipboard(&app.session, &app.diff_source) {
Ok(msg) => app.set_message(msg),
Err(e) => app.set_warning(format!("{}", e)),
}
}
_ => {
app.set_message(format!("Unknown command: {}", cmd));
}
}
app.exit_command_mode();
} else if app.input_mode == app::InputMode::Comment {
app.save_comment();
}
}
Action::ConfirmYes => {
if app.input_mode == app::InputMode::Confirm {
if let Some(app::ConfirmAction::CopyAndQuit) = app.pending_confirm {
match export_to_clipboard(&app.session, &app.diff_source) {
Ok(msg) => app.set_message(msg),
Err(e) => app.set_warning(format!("{}", e)),
}
}
app.exit_confirm_mode();
app.should_quit = true;
}
}
Action::ConfirmNo => {
if app.input_mode == app::InputMode::Confirm {
app.exit_confirm_mode();
app.should_quit = true;
}
}
Action::ExportToClipboard => {
match export_to_clipboard(&app.session, &app.diff_source) {
Ok(msg) => app.set_message(msg),
Err(e) => app.set_warning(format!("{}", e)),
}
}
Action::CommitSelectUp => app.commit_select_up(),
Action::CommitSelectDown => app.commit_select_down(),
Action::ToggleCommitSelect => app.toggle_commit_selection(),
Action::ConfirmCommitSelect => {
if let Err(e) = app.confirm_commit_selection() {
app.set_error(format!("Failed to load commits: {}", e));
}
}
_ => {}
}
}
if app.should_quit {
break;
}
}
let _ = execute!(terminal.backend_mut(), PopKeyboardEnhancementFlags);
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
Ok(())
}