src/tui/events.rs
Ref: Size: 13.0 KiB
use std::io::{self, stdout};
use std::time::Duration;
use crossterm::event::{self, Event, KeyCode};
use crossterm::terminal::{self, LeaveAlternateScreen};
use crossterm::ExecutableCommand;
use git2::Repository;
use ratatui::prelude::*;
use ratatui::widgets::ListState;
use crate::error::Error;
use crate::issue as issue_mod;
use crate::patch as patch_mod;
use super::state::{App, InputMode, KeyAction, ViewMode};
use super::widgets::ui;
pub(crate) fn run_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut App,
repo: &Repository,
) -> Result<(), Error> {
loop {
terminal.draw(|frame| ui(frame, app, Some(repo)))?;
if event::poll(Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
app.status_msg = None; // clear status on any keypress
// Input mode intercept — handle keys before normal bindings
match app.input_mode {
InputMode::Search => {
match key.code {
KeyCode::Esc => {
app.input_mode = InputMode::Normal;
app.search_query.clear();
let count = app.visible_count();
app.list_state
.select(if count > 0 { Some(0) } else { None });
}
KeyCode::Backspace => {
app.search_query.pop();
let count = app.visible_count();
app.list_state
.select(if count > 0 { Some(0) } else { None });
}
KeyCode::Char(c) => {
app.search_query.push(c);
let count = app.visible_count();
app.list_state
.select(if count > 0 { Some(0) } else { None });
}
_ => {}
}
continue;
}
InputMode::CreateTitle => {
match key.code {
KeyCode::Esc => {
app.input_mode = InputMode::Normal;
app.input_buf.clear();
}
KeyCode::Enter => {
let title = app.input_buf.trim().to_string();
if title.is_empty() {
app.input_mode = InputMode::Normal;
app.input_buf.clear();
} else {
app.create_title = title;
app.input_buf.clear();
app.input_mode = InputMode::CreateBody;
}
}
KeyCode::Backspace => {
app.input_buf.pop();
}
KeyCode::Char(c) => {
app.input_buf.push(c);
}
_ => {}
}
continue;
}
InputMode::CreateBody => {
match key.code {
KeyCode::Esc => {
// Submit with title only, no body
let title = app.create_title.clone();
match issue_mod::open(repo, &title, "", None) {
Ok(id) => {
app.reload(repo);
app.status_msg =
Some(format!("Issue created: {:.8}", id));
}
Err(e) => {
app.status_msg =
Some(format!("Error creating issue: {}", e));
}
}
app.input_mode = InputMode::Normal;
app.input_buf.clear();
app.create_title.clear();
}
KeyCode::Enter => {
let title = app.create_title.clone();
let body = app.input_buf.clone();
match issue_mod::open(repo, &title, &body, None) {
Ok(id) => {
app.reload(repo);
app.status_msg =
Some(format!("Issue created: {:.8}", id));
}
Err(e) => {
app.status_msg =
Some(format!("Error creating issue: {}", e));
}
}
app.input_mode = InputMode::Normal;
app.input_buf.clear();
app.create_title.clear();
}
KeyCode::Backspace => {
app.input_buf.pop();
}
KeyCode::Char(c) => {
app.input_buf.push(c);
}
_ => {}
}
continue;
}
InputMode::Normal => {}
}
// Handle keys that need repo access or are run_loop-specific
// before delegating to handle_key
if app.mode == ViewMode::Details {
match key.code {
KeyCode::Char('/') => {
app.input_mode = InputMode::Search;
app.search_query.clear();
continue;
}
KeyCode::Char('n') => {
app.input_mode = InputMode::CreateTitle;
app.input_buf.clear();
app.create_title.clear();
continue;
}
KeyCode::Char('o') => {
// Check out the relevant commit for local browsing
let checkout_target = {
let visible = app.visible_issues();
app.list_state
.selected()
.and_then(|idx| visible.get(idx))
.and_then(|issue| {
// Try linked patch first
app.patches
.iter()
.find(|p| p.fixes.as_deref() == Some(&issue.id))
.map(|p| p.branch.clone())
// Fall back to closing commit
.or_else(|| {
issue.closed_by.map(|oid| oid.to_string())
})
})
};
if let Some(head) = checkout_target {
// Exit TUI, checkout, and return
terminal::disable_raw_mode()?;
stdout().execute(LeaveAlternateScreen)?;
let status = std::process::Command::new("git")
.args(["checkout", &head])
.status();
match status {
Ok(s) if s.success() => {
println!("Checked out commit: {:.8}", head);
println!("Use 'git checkout -' to return.");
}
Ok(s) => {
eprintln!("git checkout exited with {}", s);
}
Err(e) => {
eprintln!("Failed to run git checkout: {}", e);
}
}
return Ok(());
} else {
app.status_msg =
Some("No linked patch to check out".to_string());
}
continue;
}
_ => {}
}
}
match app.handle_key(key.code, key.modifiers) {
KeyAction::Quit => return Ok(()),
KeyAction::Reload if app.mode == ViewMode::PatchDetail => {
// Regenerate diff for current revision/interdiff state
regenerate_patch_diff(app, repo);
}
KeyAction::Reload => app.reload(repo),
KeyAction::OpenPatchDetail => {
if let Some(patch) = app.linked_patch_for_selected().cloned() {
open_patch_detail(app, repo, patch);
}
}
KeyAction::OpenPatchDetailDirect(idx) => {
let patch = app.visible_patches().get(idx).cloned().cloned();
if let Some(patch) = patch {
open_patch_detail(app, repo, patch);
}
}
KeyAction::OpenCommitBrowser => {
if let Some(ref_name) = app.selected_ref_name() {
match crate::dag::walk_events(repo, &ref_name) {
Ok(events) => {
app.event_history = events;
app.event_list_state = ListState::default();
if !app.event_history.is_empty() {
app.event_list_state.select(Some(0));
}
app.mode = ViewMode::CommitList;
app.scroll = 0;
}
Err(e) => {
app.status_msg =
Some(format!("Error loading events: {}", e));
}
}
}
}
KeyAction::Continue => {}
}
}
}
}
}
fn generate_patch_diff_for(
repo: &Repository,
patch: &crate::state::PatchState,
rev_idx: usize,
interdiff_mode: bool,
) -> String {
if patch.revisions.is_empty() {
return "(no revisions)".to_string();
}
let rev = &patch.revisions[rev_idx];
if interdiff_mode && rev_idx > 0 {
let from_rev = patch.revisions[rev_idx - 1].number;
let to_rev = rev.number;
match patch_mod::interdiff(repo, patch, from_rev, to_rev) {
Ok(d) => d,
Err(e) => format!("(error generating interdiff: {})", e),
}
} else {
// Diff at specific revision vs base
match patch_mod::diff(repo, &patch.id, Some(rev.number), None) {
Ok(d) => d,
Err(e) => format!("(error generating diff: {})", e),
}
}
}
fn open_patch_detail(app: &mut App, repo: &Repository, patch: crate::state::PatchState) {
let warning = crate::staleness_warning(repo, &patch);
app.patch_revision_idx = patch.revisions.len().saturating_sub(1);
app.patch_interdiff_mode = false;
app.patch_scroll = 0;
let diff = generate_patch_diff_for(repo, &patch, app.patch_revision_idx, false);
app.patch_diff = diff;
app.current_patch = Some(patch);
app.mode = ViewMode::PatchDetail;
if let Some(w) = warning {
app.status_msg = Some(w);
}
}
fn regenerate_patch_diff(app: &mut App, repo: &Repository) {
if let Some(ref patch) = app.current_patch {
let diff = generate_patch_diff_for(
repo,
patch,
app.patch_revision_idx,
app.patch_interdiff_mode,
);
app.patch_diff = diff;
}
}