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
});
}
}
}
}