a73x

src/tui/state.rs

Ref:   Size: 16.6 KiB

use crossterm::event::{KeyCode, KeyModifiers};
use git2::{Oid, Repository};
use ratatui::widgets::ListState;

use crate::state::{self, IssueState, IssueStatus, PatchState, PatchStatus};

#[derive(Debug, PartialEq)]
pub(crate) enum Pane {
    ItemList,
    Detail,
}

#[derive(Debug, PartialEq)]
pub(crate) enum ViewMode {
    Details,
    CommitList,
    CommitDetail,
    PatchDetail,
}

#[derive(Debug, PartialEq)]
pub(crate) enum KeyAction {
    Continue,
    Quit,
    Reload,
    OpenCommitBrowser,
    OpenPatchDetail,
    OpenPatchDetailDirect(usize), // index into visible_patches
}

#[derive(Debug, PartialEq, Clone, Copy)]
pub(crate) enum StatusFilter {
    Open,
    Closed,
    All,
}

impl StatusFilter {
    pub(crate) fn next(self) -> Self {
        match self {
            StatusFilter::Open => StatusFilter::All,
            StatusFilter::All => StatusFilter::Closed,
            StatusFilter::Closed => StatusFilter::Open,
        }
    }

    pub(crate) fn label(self) -> &'static str {
        match self {
            StatusFilter::Open => "open",
            StatusFilter::Closed => "closed",
            StatusFilter::All => "all",
        }
    }
}

#[derive(Debug, PartialEq, Clone, Copy)]
pub(crate) enum ListMode {
    Issues,
    Patches,
}

#[derive(Debug, PartialEq)]
pub(crate) enum InputMode {
    Normal,
    Search,
    CreateTitle,
    CreateBody,
}

pub(crate) struct App {
    pub(crate) issues: Vec<IssueState>,
    pub(crate) patches: Vec<PatchState>,
    pub(crate) list_state: ListState,
    pub(crate) patch_list_state: ListState,
    pub(crate) list_mode: ListMode,
    pub(crate) scroll: u16,
    pub(crate) pane: Pane,
    pub(crate) mode: ViewMode,
    pub(crate) status_filter: StatusFilter,
    pub(crate) search_query: String,
    pub(crate) input_mode: InputMode,
    pub(crate) input_buf: String,
    pub(crate) create_title: String,
    pub(crate) status_msg: Option<String>,
    pub(crate) event_history: Vec<(Oid, crate::event::Event)>,
    pub(crate) event_list_state: ListState,
    // Patch detail view state
    pub(crate) current_patch: Option<PatchState>,
    pub(crate) patch_diff: String,
    pub(crate) patch_scroll: u16,
    pub(crate) patch_revision_idx: usize,
    pub(crate) patch_interdiff_mode: bool,
}

impl App {
    pub(crate) fn new(issues: Vec<IssueState>, patches: Vec<PatchState>) -> Self {
        let mut list_state = ListState::default();
        if !issues.is_empty() {
            list_state.select(Some(0));
        }
        let mut patch_list_state = ListState::default();
        if !patches.is_empty() {
            patch_list_state.select(Some(0));
        }
        Self {
            issues,
            patches,
            list_state,
            patch_list_state,
            list_mode: ListMode::Issues,
            scroll: 0,
            pane: Pane::ItemList,
            mode: ViewMode::Details,
            status_filter: StatusFilter::Open,
            search_query: String::new(),
            input_mode: InputMode::Normal,
            input_buf: String::new(),
            create_title: String::new(),
            status_msg: None,
            event_history: Vec::new(),
            event_list_state: ListState::default(),
            current_patch: None,
            patch_diff: String::new(),
            patch_scroll: 0,
            patch_revision_idx: 0,
            patch_interdiff_mode: false,
        }
    }

    pub(crate) fn matches_search(&self, title: &str) -> bool {
        if self.search_query.is_empty() {
            return true;
        }
        title
            .to_lowercase()
            .contains(&self.search_query.to_lowercase())
    }

    pub(crate) fn visible_issues(&self) -> Vec<&IssueState> {
        self.issues
            .iter()
            .filter(|i| match self.status_filter {
                StatusFilter::Open => i.status == IssueStatus::Open,
                StatusFilter::Closed => i.status == IssueStatus::Closed,
                StatusFilter::All => true,
            })
            .filter(|i| self.matches_search(&i.title))
            .collect()
    }

    pub(crate) fn visible_patches(&self) -> Vec<&PatchState> {
        self.patches
            .iter()
            .filter(|p| match self.status_filter {
                StatusFilter::Open => p.status == PatchStatus::Open,
                StatusFilter::Closed => p.status == PatchStatus::Closed || p.status == PatchStatus::Merged,
                StatusFilter::All => true,
            })
            .filter(|p| self.matches_search(&p.title))
            .collect()
    }

    pub(crate) fn visible_count(&self) -> usize {
        match self.list_mode {
            ListMode::Issues => self.visible_issues().len(),
            ListMode::Patches => self.visible_patches().len(),
        }
    }

    pub(crate) fn move_selection(&mut self, delta: i32) {
        let len = self.visible_count();
        if len == 0 {
            return;
        }
        let state = match self.list_mode {
            ListMode::Issues => &mut self.list_state,
            ListMode::Patches => &mut self.patch_list_state,
        };
        let current = state.selected().unwrap_or(0);
        let new = if delta > 0 {
            (current + delta as usize).min(len - 1)
        } else {
            current.saturating_sub((-delta) as usize)
        };
        state.select(Some(new));
        self.scroll = 0;
    }

    /// Find the first linked patch for the currently selected issue.
    pub(crate) fn linked_patch_for_selected(&self) -> Option<&PatchState> {
        let idx = self.list_state.selected()?;
        let visible = self.visible_issues();
        let issue = visible.get(idx)?;
        self.patches
            .iter()
            .find(|p| p.fixes.as_deref() == Some(&issue.id))
    }

    pub(crate) fn handle_key(&mut self, code: KeyCode, modifiers: KeyModifiers) -> KeyAction {
        // Handle PatchDetail mode
        if self.mode == ViewMode::PatchDetail {
            match code {
                KeyCode::Esc => {
                    self.mode = ViewMode::Details;
                    self.pane = Pane::ItemList;
                    self.current_patch = None;
                    self.patch_diff.clear();
                    self.patch_scroll = 0;
                    self.patch_revision_idx = 0;
                    self.patch_interdiff_mode = false;
                    return KeyAction::Continue;
                }
                KeyCode::Char('q') => return KeyAction::Quit,
                KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => {
                    return KeyAction::Quit;
                }
                KeyCode::Char('j') | KeyCode::Down => {
                    self.patch_scroll = self.patch_scroll.saturating_add(1);
                    return KeyAction::Continue;
                }
                KeyCode::Char('k') | KeyCode::Up => {
                    self.patch_scroll = self.patch_scroll.saturating_sub(1);
                    return KeyAction::Continue;
                }
                KeyCode::PageDown => {
                    self.patch_scroll = self.patch_scroll.saturating_add(20);
                    return KeyAction::Continue;
                }
                KeyCode::PageUp => {
                    self.patch_scroll = self.patch_scroll.saturating_sub(20);
                    return KeyAction::Continue;
                }
                KeyCode::Char(']') => {
                    if let Some(ref patch) = self.current_patch {
                        let max = patch.revisions.len().saturating_sub(1);
                        if self.patch_revision_idx < max {
                            self.patch_revision_idx += 1;
                            self.patch_scroll = 0;
                            return KeyAction::Reload; // signal to regenerate diff
                        }
                    }
                    return KeyAction::Continue;
                }
                KeyCode::Char('[') => {
                    if self.patch_revision_idx > 0 {
                        self.patch_revision_idx -= 1;
                        self.patch_scroll = 0;
                        return KeyAction::Reload; // signal to regenerate diff
                    }
                    return KeyAction::Continue;
                }
                KeyCode::Char('d') => {
                    self.patch_interdiff_mode = !self.patch_interdiff_mode;
                    self.patch_scroll = 0;
                    return KeyAction::Reload; // signal to regenerate diff
                }
                _ => return KeyAction::Continue,
            }
        }

        // Handle CommitDetail mode first
        if self.mode == ViewMode::CommitDetail {
            match code {
                KeyCode::Esc => {
                    self.mode = ViewMode::CommitList;
                    self.scroll = 0;
                    return KeyAction::Continue;
                }
                KeyCode::Char('q') => return KeyAction::Quit,
                KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => {
                    return KeyAction::Quit;
                }
                KeyCode::Char('j') | KeyCode::Down => {
                    self.scroll = self.scroll.saturating_add(1);
                    return KeyAction::Continue;
                }
                KeyCode::Char('k') | KeyCode::Up => {
                    self.scroll = self.scroll.saturating_sub(1);
                    return KeyAction::Continue;
                }
                KeyCode::PageDown => {
                    self.scroll = self.scroll.saturating_add(20);
                    return KeyAction::Continue;
                }
                KeyCode::PageUp => {
                    self.scroll = self.scroll.saturating_sub(20);
                    return KeyAction::Continue;
                }
                _ => return KeyAction::Continue,
            }
        }

        // Handle CommitList mode
        if self.mode == ViewMode::CommitList {
            match code {
                KeyCode::Esc => {
                    self.event_history.clear();
                    self.event_list_state = ListState::default();
                    self.mode = ViewMode::Details;
                    self.scroll = 0;
                    return KeyAction::Continue;
                }
                KeyCode::Char('q') => return KeyAction::Quit,
                KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => {
                    return KeyAction::Quit;
                }
                KeyCode::Char('j') | KeyCode::Down => {
                    let len = self.event_history.len();
                    if len > 0 {
                        let current = self.event_list_state.selected().unwrap_or(0);
                        let new = (current + 1).min(len - 1);
                        self.event_list_state.select(Some(new));
                    }
                    return KeyAction::Continue;
                }
                KeyCode::Char('k') | KeyCode::Up => {
                    if !self.event_history.is_empty() {
                        let current = self.event_list_state.selected().unwrap_or(0);
                        let new = current.saturating_sub(1);
                        self.event_list_state.select(Some(new));
                    }
                    return KeyAction::Continue;
                }
                KeyCode::Enter => {
                    if self.event_list_state.selected().is_some() {
                        self.mode = ViewMode::CommitDetail;
                        self.scroll = 0;
                    }
                    return KeyAction::Continue;
                }
                _ => return KeyAction::Continue,
            }
        }

        // Normal Details mode handling
        match code {
            KeyCode::Char('q') | KeyCode::Esc => KeyAction::Quit,
            KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => KeyAction::Quit,
            KeyCode::Char('P') => {
                // Toggle between Issues and Patches list mode
                self.list_mode = match self.list_mode {
                    ListMode::Issues => ListMode::Patches,
                    ListMode::Patches => ListMode::Issues,
                };
                self.scroll = 0;
                self.pane = Pane::ItemList;
                self.mode = ViewMode::Details;
                KeyAction::Continue
            }
            KeyCode::Char('c') => {
                // Open commit browser: only when in detail pane with an item selected
                if self.pane == Pane::Detail
                    && self.list_mode == ListMode::Issues
                    && self.list_state.selected().is_some()
                {
                    KeyAction::OpenCommitBrowser
                } else {
                    KeyAction::Continue
                }
            }
            KeyCode::Char('p') => {
                // Open patch detail: only when in detail pane with a linked patch (issues mode)
                if self.pane == Pane::Detail
                    && self.list_mode == ListMode::Issues
                    && self.linked_patch_for_selected().is_some()
                {
                    KeyAction::OpenPatchDetail
                } else {
                    KeyAction::Continue
                }
            }
            KeyCode::Char('j') | KeyCode::Down => {
                if self.pane == Pane::ItemList {
                    self.move_selection(1);
                } else {
                    self.scroll = self.scroll.saturating_add(1);
                }
                KeyAction::Continue
            }
            KeyCode::Char('k') | KeyCode::Up => {
                if self.pane == Pane::ItemList {
                    self.move_selection(-1);
                } else {
                    self.scroll = self.scroll.saturating_sub(1);
                }
                KeyAction::Continue
            }
            KeyCode::PageDown => {
                self.scroll = self.scroll.saturating_add(20);
                KeyAction::Continue
            }
            KeyCode::PageUp => {
                self.scroll = self.scroll.saturating_sub(20);
                KeyAction::Continue
            }
            KeyCode::Tab | KeyCode::Enter => {
                // In Patches list mode, entering detail pane opens patch detail directly
                if self.list_mode == ListMode::Patches && self.pane == Pane::ItemList {
                    if let Some(idx) = self.patch_list_state.selected() {
                        let visible_len = self.visible_patches().len();
                        if idx < visible_len {
                            return KeyAction::OpenPatchDetailDirect(idx);
                        }
                    }
                }
                self.pane = match self.pane {
                    Pane::ItemList => Pane::Detail,
                    Pane::Detail => Pane::ItemList,
                };
                KeyAction::Continue
            }
            KeyCode::Char('a') => {
                self.status_filter = self.status_filter.next();
                let count = self.visible_count();
                let state = match self.list_mode {
                    ListMode::Issues => &mut self.list_state,
                    ListMode::Patches => &mut self.patch_list_state,
                };
                state.select(if count > 0 { Some(0) } else { None });
                KeyAction::Continue
            }
            KeyCode::Char('r') => KeyAction::Reload,
            _ => KeyAction::Continue,
        }
    }

    pub(crate) fn selected_item_id(&self) -> Option<String> {
        let idx = self.list_state.selected()?;
        let visible = self.visible_issues();
        visible.get(idx).map(|i| i.id.clone())
    }

    pub(crate) fn selected_ref_name(&self) -> Option<String> {
        let id = self.selected_item_id()?;
        Some(format!("refs/collab/issues/{}", id))
    }

    pub(crate) fn reload(&mut self, repo: &Repository) {
        if let Ok(issues) = state::list_issues(repo) {
            self.issues = issues;
        }
        if let Ok(patches) = state::list_patches(repo) {
            self.patches = patches;
        }
        // Clamp issue list selection
        let issue_len = self.visible_issues().len();
        if let Some(sel) = self.list_state.selected() {
            if sel >= issue_len {
                self.list_state.select(if issue_len > 0 {
                    Some(issue_len - 1)
                } else {
                    None
                });
            }
        }
        // Clamp patch list selection
        let patch_len = self.visible_patches().len();
        if let Some(sel) = self.patch_list_state.selected() {
            if sel >= patch_len {
                self.patch_list_state.select(if patch_len > 0 {
                    Some(patch_len - 1)
                } else {
                    None
                });
            }
        }
    }
}