use std::path::PathBuf;
use ratatui::widgets::ListState;
use throbber_widgets_tui::ThrobberState;
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum LoadingOperation {
#[default]
Idle,
Fetching,
Pulling { path: PathBuf },
Deleting { path: PathBuf },
FetchingIssues,
FetchingIssue { issue_number: u32 },
GeneratingActions { issue_number: u32 },
}
impl LoadingOperation {
#[must_use]
pub const fn message(&self) -> &'static str {
match self {
Self::Idle => "",
Self::Fetching => "Fetching...",
Self::Pulling { .. } => "Pulling...",
Self::Deleting { .. } => "Deleting...",
Self::FetchingIssues => "Loading issues...",
Self::FetchingIssue { .. } => "Fetching issue...",
Self::GeneratingActions { .. } => "Generating actions...",
}
}
#[must_use]
pub const fn is_loading(&self) -> bool {
!matches!(self, Self::Idle)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PopupSection {
#[default]
BranchInput,
Sessions,
Worktrees,
Issues,
}
impl PopupSection {
#[must_use]
pub fn next(self) -> Self {
match self {
Self::BranchInput => Self::Sessions,
Self::Sessions => Self::Worktrees,
Self::Worktrees => Self::Issues,
Self::Issues => Self::BranchInput,
}
}
#[must_use]
pub fn prev(self) -> Self {
match self {
Self::BranchInput => Self::Issues,
Self::Sessions => Self::BranchInput,
Self::Worktrees => Self::Sessions,
Self::Issues => Self::Worktrees,
}
}
}
#[derive(Debug, Default)]
pub struct WorkspacePopupState {
pub section: PopupSection,
pub input: String,
pub cursor: usize,
pub session_list: ListState,
pub worktree_list: ListState,
pub issue_list: ListState,
pub loading: LoadingOperation,
pub throbber_state: ThrobberState,
}
impl WorkspacePopupState {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn next_section(&mut self) {
self.section = self.section.next();
}
pub fn prev_section(&mut self) {
self.section = self.section.prev();
}
pub fn input_char(&mut self, c: char) {
self.input.insert(self.cursor, c);
self.cursor += c.len_utf8();
}
pub fn input_backspace(&mut self) {
if self.cursor > 0 {
let prev_char_boundary = self
.input
.char_indices()
.take_while(|(i, _)| *i < self.cursor)
.last()
.map_or(0, |(i, _)| i);
self.input.remove(prev_char_boundary);
self.cursor = prev_char_boundary;
}
}
pub fn cursor_left(&mut self) {
if self.cursor > 0 {
self.cursor = self
.input
.char_indices()
.take_while(|(i, _)| *i < self.cursor)
.last()
.map_or(0, |(i, _)| i);
}
}
pub fn cursor_right(&mut self) {
if self.cursor < self.input.len() {
self.cursor = self
.input
.char_indices()
.find(|(i, _)| *i > self.cursor)
.map_or(self.input.len(), |(i, _)| i);
}
}
pub fn clear_input(&mut self) {
self.input.clear();
self.cursor = 0;
}
pub fn tick_spinner(&mut self) {
if self.loading.is_loading() {
self.throbber_state.calc_next();
}
}
#[must_use]
pub fn is_loading(&self) -> bool {
self.loading.is_loading()
}
#[must_use]
pub fn loading_message(&self) -> &'static str {
self.loading.message()
}
}
#[derive(Debug, Default)]
pub struct ActionSelectPopupState {
pub list_state: ListState,
}
impl ActionSelectPopupState {
#[must_use]
pub fn new() -> Self {
let mut state = Self::default();
state.list_state.select(Some(0));
state
}
pub fn select_next(&mut self, len: usize) {
if len == 0 {
return;
}
let i = match self.list_state.selected() {
Some(i) => (i + 1) % len,
None => 0,
};
self.list_state.select(Some(i));
}
pub fn select_prev(&mut self, len: usize) {
if len == 0 {
return;
}
let i = match self.list_state.selected() {
Some(i) => {
if i == 0 {
len - 1
} else {
i - 1
}
}
None => 0,
};
self.list_state.select(Some(i));
}
#[must_use]
pub fn selected(&self) -> Option<usize> {
self.list_state.selected()
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn popup_section_default_is_branch_input() {
assert_eq!(PopupSection::default(), PopupSection::BranchInput);
}
#[test]
fn popup_section_next_cycles() {
assert_eq!(PopupSection::BranchInput.next(), PopupSection::Sessions);
assert_eq!(PopupSection::Sessions.next(), PopupSection::Worktrees);
assert_eq!(PopupSection::Worktrees.next(), PopupSection::Issues);
assert_eq!(PopupSection::Issues.next(), PopupSection::BranchInput);
}
#[test]
fn popup_section_prev_cycles() {
assert_eq!(PopupSection::BranchInput.prev(), PopupSection::Issues);
assert_eq!(PopupSection::Sessions.prev(), PopupSection::BranchInput);
assert_eq!(PopupSection::Worktrees.prev(), PopupSection::Sessions);
assert_eq!(PopupSection::Issues.prev(), PopupSection::Worktrees);
}
#[test]
fn workspace_popup_state_default() {
let state = WorkspacePopupState::new();
assert_eq!(state.section, PopupSection::BranchInput);
assert!(state.input.is_empty());
assert_eq!(state.cursor, 0);
}
#[test]
fn workspace_popup_state_section_navigation() {
let mut state = WorkspacePopupState::new();
assert_eq!(state.section, PopupSection::BranchInput);
state.next_section();
assert_eq!(state.section, PopupSection::Sessions);
state.next_section();
assert_eq!(state.section, PopupSection::Worktrees);
state.next_section();
assert_eq!(state.section, PopupSection::Issues);
state.next_section();
assert_eq!(state.section, PopupSection::BranchInput);
state.prev_section();
assert_eq!(state.section, PopupSection::Issues);
state.prev_section();
assert_eq!(state.section, PopupSection::Worktrees);
}
#[test]
fn workspace_popup_state_input_char() {
let mut state = WorkspacePopupState::new();
state.input_char('f');
state.input_char('o');
state.input_char('o');
assert_eq!(state.input, "foo");
assert_eq!(state.cursor, 3);
}
#[test]
fn workspace_popup_state_input_backspace() {
let mut state = WorkspacePopupState::new();
state.input_char('a');
state.input_char('b');
state.input_char('c');
assert_eq!(state.input, "abc");
state.input_backspace();
assert_eq!(state.input, "ab");
assert_eq!(state.cursor, 2);
state.input_backspace();
assert_eq!(state.input, "a");
assert_eq!(state.cursor, 1);
}
#[test]
fn workspace_popup_state_input_backspace_empty() {
let mut state = WorkspacePopupState::new();
state.input_backspace(); assert!(state.input.is_empty());
assert_eq!(state.cursor, 0);
}
#[test]
fn workspace_popup_state_cursor_movement() {
let mut state = WorkspacePopupState::new();
state.input_char('a');
state.input_char('b');
state.input_char('c');
assert_eq!(state.cursor, 3);
state.cursor_left();
assert_eq!(state.cursor, 2);
state.cursor_left();
assert_eq!(state.cursor, 1);
state.cursor_right();
assert_eq!(state.cursor, 2);
state.cursor_right();
assert_eq!(state.cursor, 3);
state.cursor_right();
assert_eq!(state.cursor, 3);
state.cursor = 0;
state.cursor_left();
assert_eq!(state.cursor, 0);
}
#[test]
fn workspace_popup_state_clear_input() {
let mut state = WorkspacePopupState::new();
state.input_char('t');
state.input_char('e');
state.input_char('s');
state.input_char('t');
state.clear_input();
assert!(state.input.is_empty());
assert_eq!(state.cursor, 0);
}
#[test]
fn workspace_popup_state_unicode_input() {
let mut state = WorkspacePopupState::new();
state.input_char('æ—¥');
state.input_char('本');
assert_eq!(state.input, "日本");
assert_eq!(state.cursor, 6);
state.input_backspace();
assert_eq!(state.input, "æ—¥");
assert_eq!(state.cursor, 3);
}
use rstest::rstest;
#[test]
fn loading_operation_default_is_idle() {
assert_eq!(LoadingOperation::default(), LoadingOperation::Idle);
}
#[rstest]
#[case(LoadingOperation::Idle, false, "")]
#[case(LoadingOperation::Fetching, true, "Fetching...")]
#[case(LoadingOperation::Pulling { path: PathBuf::from("/test") }, true, "Pulling...")]
#[case(LoadingOperation::Deleting { path: PathBuf::from("/test") }, true, "Deleting...")]
#[case(LoadingOperation::FetchingIssues, true, "Loading issues...")]
#[case(LoadingOperation::FetchingIssue { issue_number: 1 }, true, "Fetching issue...")]
#[case(LoadingOperation::GeneratingActions { issue_number: 42 }, true, "Generating actions...")]
fn loading_operation_behavior(
#[case] op: LoadingOperation,
#[case] expected_loading: bool,
#[case] expected_message: &str,
) {
assert_eq!(op.is_loading(), expected_loading);
assert_eq!(op.message(), expected_message);
}
#[test]
fn workspace_popup_state_is_loading() {
let mut state = WorkspacePopupState::new();
assert!(!state.is_loading());
state.loading = LoadingOperation::Fetching;
assert!(state.is_loading());
state.loading = LoadingOperation::Idle;
assert!(!state.is_loading());
}
#[test]
fn workspace_popup_state_tick_spinner_when_loading() {
let mut state = WorkspacePopupState::new();
state.tick_spinner();
state.loading = LoadingOperation::Fetching;
state.tick_spinner();
}
#[test]
fn loading_operation_eq() {
assert_eq!(LoadingOperation::Idle, LoadingOperation::Idle);
assert_eq!(LoadingOperation::Fetching, LoadingOperation::Fetching);
assert_eq!(
LoadingOperation::Pulling {
path: PathBuf::from("/a")
},
LoadingOperation::Pulling {
path: PathBuf::from("/a")
}
);
assert_eq!(
LoadingOperation::Deleting {
path: PathBuf::from("/b")
},
LoadingOperation::Deleting {
path: PathBuf::from("/b")
}
);
assert_eq!(
LoadingOperation::FetchingIssues,
LoadingOperation::FetchingIssues
);
assert_eq!(
LoadingOperation::FetchingIssue { issue_number: 1 },
LoadingOperation::FetchingIssue { issue_number: 1 }
);
assert_eq!(
LoadingOperation::GeneratingActions { issue_number: 42 },
LoadingOperation::GeneratingActions { issue_number: 42 }
);
}
#[test]
fn action_select_popup_state_new() {
let state = ActionSelectPopupState::new();
assert_eq!(state.selected(), Some(0));
}
#[test]
fn action_select_popup_state_select_next() {
let mut state = ActionSelectPopupState::new();
state.select_next(3);
assert_eq!(state.selected(), Some(1));
state.select_next(3);
assert_eq!(state.selected(), Some(2));
state.select_next(3);
assert_eq!(state.selected(), Some(0)); }
#[test]
fn action_select_popup_state_select_prev() {
let mut state = ActionSelectPopupState::new();
state.select_prev(3);
assert_eq!(state.selected(), Some(2)); state.select_prev(3);
assert_eq!(state.selected(), Some(1));
}
#[test]
fn action_select_popup_state_empty_list() {
let mut state = ActionSelectPopupState::new();
state.select_next(0); state.select_prev(0); }
#[test]
fn action_select_popup_state_select_next_no_initial_selection() {
let mut state = ActionSelectPopupState::new();
state.list_state = ListState::default();
assert_eq!(state.selected(), None);
state.select_next(3);
assert_eq!(state.selected(), Some(0));
}
#[test]
fn action_select_popup_state_select_prev_no_initial_selection() {
let mut state = ActionSelectPopupState::new();
state.list_state = ListState::default();
assert_eq!(state.selected(), None);
state.select_prev(3);
assert_eq!(state.selected(), Some(0));
}
}