Jesús Pérez 9cef9b8d57 refactor: consolidate configuration directories
Merge _configs/ into config/ for single configuration directory.
Update all path references.

Changes:
- Move _configs/* to config/
- Update .gitignore for new patterns
- No code references to _configs/ found

Impact: -1 root directory (layout_conventions.md compliance)
2025-12-26 18:36:23 +00:00

1433 lines
44 KiB
Rust

//! Application state management - R-STATE-SEPARATION pattern
//!
//! This module implements the R-STATE-SEPARATION design pattern:
//! - Application state is kept completely separate from rendering logic
//! - All state mutations happen through explicit methods
//! - Dirty flag marks when state has changed and redraw is needed
//! - No rendering logic here - that's in ui.rs
use crate::api::ApiClient;
use crate::types::{ChecklistItemDto, ProjectDto, TaskPriority};
use anyhow::Result;
use ratatui::widgets::ListState;
use tui_textarea::TextArea;
/// Application screens - Navigation state
///
/// # R-STATE-SEPARATION
/// Screens are part of state management, not rendering.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Screen {
/// Projects list screen
ProjectsList,
/// Project detail screen
ProjectDetail,
/// Security assessment screen
Security,
/// Create project screen
CreateProject,
/// Tasks/checklist screen
Tasks,
/// Create/edit task screen
CreateTask,
/// Edit existing task screen
EditTask,
/// Task filter and sort screen
TasksFilter,
/// Delete confirmation modal
DeleteConfirm,
/// Keyboard shortcuts help screen
Help,
/// Theme selector screen
ThemeSelector,
}
/// Sort options for tasks
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) enum TaskSortOption {
/// Newest first (by creation date)
#[default]
CreatedNewest,
/// Oldest first (by creation date)
CreatedOldest,
/// Highest priority first (Critical → High → Medium → Low)
PriorityHighest,
/// Lowest priority first (Low → Medium → High → Critical)
PriorityLowest,
/// Completed tasks first
CompletedFirst,
/// Pending tasks first
PendingFirst,
}
impl TaskSortOption {
/// Get all available sort options
pub(crate) fn all() -> &'static [Self] {
&[
Self::CreatedNewest,
Self::CreatedOldest,
Self::PriorityHighest,
Self::PriorityLowest,
Self::CompletedFirst,
Self::PendingFirst,
]
}
/// Get display label for this sort option
pub(crate) fn label(&self) -> &'static str {
match self {
Self::CreatedNewest => "Newest First",
Self::CreatedOldest => "Oldest First",
Self::PriorityHighest => "Highest Priority",
Self::PriorityLowest => "Lowest Priority",
Self::CompletedFirst => "Completed First",
Self::PendingFirst => "Pending First",
}
}
}
/// Delete confirmation mode
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DeleteMode {
/// Show modal dialog for confirmation
Modal,
/// Show inline message, require second press
Inline,
/// Show prompt in status bar
Prompt,
}
impl DeleteMode {
/// Parse from string
pub(crate) fn from_str(s: &str) -> Self {
match s.to_lowercase().as_str() {
"modal" => Self::Modal,
"inline" => Self::Inline,
"prompt" => Self::Prompt,
_ => Self::Modal, // Default to modal
}
}
}
/// Application state - Complete immutable snapshot
///
/// # R-STATE-SEPARATION Pattern
///
/// This struct contains ALL mutable state. All mutations go through explicit methods
/// (see `handle_*` methods below). The rendering layer (ui.rs) reads this state
/// but NEVER modifies it - rendering functions take `&App`, never `&mut App`.
///
/// ## Dirty Flag Optimization
/// The `dirty` flag tracks whether state has changed since last render.
/// Only redraw when `dirty` is true, then set to false.
pub(crate) struct App {
// === Rendering state ===
/// Current screen (navigation state)
pub screen: Screen,
/// Dirty flag: true if state changed, needs redraw
pub dirty: bool,
// === Projects data ===
/// Loaded projects list
pub projects: Vec<ProjectDto>,
/// Projects list state (R-LIST-STATE pattern)
pub projects_list_state: ListState,
/// Detailed view of selected project
pub current_project: Option<ProjectDto>,
// === Tasks/Checklist data ===
/// Loaded checklist items for current project
pub tasks: Vec<ChecklistItemDto>,
/// Tasks list state (R-LIST-STATE pattern)
pub tasks_list_state: ListState,
// === Loading/Error state ===
/// Whether currently loading from API
pub is_loading: bool,
/// Error message to display (None = no error)
pub error: Option<String>,
// === Form input state ===
/// Project name input field (create form)
pub project_name_input: String,
/// Project description input field (create form)
pub project_desc_input: String,
// === Task creation/editing state ===
/// Task description textarea (R-TEXTAREA-BASIC pattern)
pub task_textarea: TextArea<'static>,
/// Task creation mode: true for new, false for editing
pub task_creation_mode: bool,
/// Form error message during task creation
pub task_form_error: Option<String>,
/// ID of task being edited (None = creating new)
pub editing_task_id: Option<String>,
// === Task filtering/search state ===
/// Search query for task description/ID
pub task_search_query: String,
/// Filter by completion status: None = all, Some(true) = completed, Some(false) = pending
pub task_filter_completed: Option<bool>,
/// Current sort option for tasks
pub task_sort: TaskSortOption,
/// Currently selected sort option index (for navigation in filter menu)
pub filter_sort_selected: usize,
// === Pagination state ===
/// Current page for task pagination
pub current_page: usize,
/// Total tasks count (from server)
pub total_tasks: usize,
/// Tasks per page
pub tasks_per_page: usize,
// === Delete confirmation state ===
/// Delete confirmation mode (modal, inline, prompt)
pub delete_confirmation_mode: DeleteMode,
/// Whether delete action is pending confirmation (inline mode)
pub delete_confirm_pending: bool,
/// Whether awaiting delete confirmation input (prompt mode)
pub awaiting_delete_confirm: bool,
// === Theme and layout state ===
/// Current theme name
pub current_theme: String,
/// Selected theme index in theme selector
pub theme_selector_index: usize,
/// Task detail width percentage
pub task_detail_width_percent: u16,
// === Help screen state ===
/// Help screen scroll offset
pub help_scroll_offset: usize,
// === Application lifecycle ===
/// Should quit application (Ctrl+C or 'q')
pub should_quit: bool,
// === External dependencies ===
/// API client for data fetching
pub api: ApiClient,
}
impl App {
// === Lifecycle Methods ===
/// Create a new app instance with default state
///
/// # Dirty Flag
/// Starts with `dirty: true` to force initial render
pub(crate) fn new() -> Self {
let mut projects_list_state = ListState::default();
projects_list_state.select(Some(0));
let mut tasks_list_state = ListState::default();
tasks_list_state.select(Some(0));
Self {
screen: Screen::ProjectsList,
dirty: true, // Initial render needed
projects: Vec::new(),
projects_list_state,
current_project: None,
tasks: Vec::new(),
tasks_list_state,
is_loading: true,
error: None,
api: ApiClient::new(),
project_name_input: String::new(),
project_desc_input: String::new(),
task_textarea: TextArea::default(),
task_creation_mode: true,
task_form_error: None,
editing_task_id: None,
task_search_query: String::new(),
task_filter_completed: None,
task_sort: TaskSortOption::default(),
filter_sort_selected: 0,
current_page: 0,
total_tasks: 0,
tasks_per_page: 50,
delete_confirmation_mode: DeleteMode::Modal,
delete_confirm_pending: false,
awaiting_delete_confirm: false,
current_theme: "default".to_string(),
theme_selector_index: 0,
task_detail_width_percent: 50,
help_scroll_offset: 0,
should_quit: false,
}
}
// === Data Loading Methods ===
/// Load projects from the API
///
/// # Side Effects
/// - Sets `is_loading` true before fetch
/// - Sets `dirty = true` after fetch completes
/// - Clears any previous error
pub(crate) async fn load_projects(&mut self) -> Result<()> {
self.is_loading = true;
self.error = None;
self.dirty = true;
match self.api.list_projects().await {
Ok(projects) => {
self.projects = projects;
self.is_loading = false;
self.dirty = true; // Data changed, redraw needed
Ok(())
}
Err(e) => {
self.error = Some(format!("Failed to load projects: {}", e));
self.is_loading = false;
self.dirty = true; // Error state changed, redraw needed
Err(e)
}
}
}
/// Load project detail from the API
///
/// # Side Effects
/// - Sets `is_loading` true before fetch
/// - Sets `dirty = true` after fetch completes
#[allow(dead_code)]
pub(crate) async fn load_project_detail(&mut self, id: &str) -> Result<()> {
self.is_loading = true;
self.error = None;
self.dirty = true;
match self.api.get_project(id).await {
Ok(project) => {
self.current_project = Some(project);
self.is_loading = false;
self.dirty = true;
Ok(())
}
Err(e) => {
self.error = Some(format!("Failed to load project: {}", e));
self.is_loading = false;
self.dirty = true;
Err(e)
}
}
}
// === Navigation Methods ===
/// Select currently highlighted project and show detail view
///
/// # Dirty Flag
/// Sets `dirty = true` when screen changes
/// # R-LIST-STATE Pattern
/// Uses ListState for proper list selection management
pub(crate) fn select_project(&mut self) {
if let Some(selected) = self.projects_list_state.selected() {
if let Some(project) = self.projects.get(selected) {
self.current_project = Some(project.clone());
self.screen = Screen::ProjectDetail;
self.dirty = true;
}
}
}
/// Move selection down one project
///
/// # Behavior
/// - Wraps around to top when at bottom
/// - Only works if projects list is not empty
/// - Sets `dirty = true`
/// # R-LIST-STATE Pattern
/// Uses ListState for proper selection
pub(crate) fn nav_down(&mut self) {
if !self.projects.is_empty() {
if let Some(selected) = self.projects_list_state.selected() {
let next = if selected >= self.projects.len().saturating_sub(1) {
0 // Wrap to top
} else {
selected + 1
};
self.projects_list_state.select(Some(next));
}
self.dirty = true;
}
}
/// Move selection up one project
///
/// # Behavior
/// - Wraps around to bottom when at top
/// - Only works if projects list is not empty
/// - Sets `dirty = true`
/// # R-LIST-STATE Pattern
/// Uses ListState for proper selection
pub(crate) fn nav_up(&mut self) {
if !self.projects.is_empty() {
if let Some(selected) = self.projects_list_state.selected() {
let prev = if selected == 0 {
self.projects.len().saturating_sub(1) // Wrap to bottom
} else {
selected - 1
};
self.projects_list_state.select(Some(prev));
}
self.dirty = true;
}
}
/// Go back from detail view to projects list
///
/// # Dirty Flag
/// Sets `dirty = true` when screen changes
pub(crate) fn back_to_projects(&mut self) {
self.screen = Screen::ProjectsList;
self.current_project = None;
self.dirty = true;
}
/// Navigate to security assessment screen
pub(crate) fn view_security(&mut self) {
self.screen = Screen::Security;
self.dirty = true;
}
/// Navigate to create project screen
pub(crate) fn create_project_screen(&mut self) {
self.screen = Screen::CreateProject;
self.reset_create_project_form();
self.dirty = true;
}
/// Load tasks from the API for the current project
///
/// # Side Effects
/// - Sets `is_loading` true before fetch
/// - Sets `dirty = true` after fetch completes
/// - Clears any previous error
pub(crate) async fn load_tasks(&mut self) -> Result<()> {
if let Some(project) = &self.current_project {
self.is_loading = true;
self.error = None;
self.dirty = true;
self.tasks_list_state.select(Some(0)); // Reset task selection
match self.api.get_checklists(&project.id).await {
Ok(tasks) => {
self.tasks = tasks;
self.is_loading = false;
self.dirty = true;
Ok(())
}
Err(e) => {
self.error = Some(format!("Failed to load tasks: {}", e));
self.is_loading = false;
self.dirty = true;
Err(e)
}
}
} else {
Err(anyhow::anyhow!("No project selected"))
}
}
/// Navigate to tasks screen for the currently selected project
///
/// # Dirty Flag
/// Sets `dirty = true` when screen changes
pub(crate) fn view_tasks(&mut self) {
if self.current_project.is_some() {
self.screen = Screen::Tasks;
self.tasks_list_state.select(Some(0));
self.dirty = true;
}
}
/// Move task selection down one position
///
/// # Behavior
/// - Wraps around to top when at bottom
/// - Only works if tasks list is not empty
/// - Sets `dirty = true`
/// # R-LIST-STATE Pattern
/// Uses ListState for proper selection
pub(crate) fn task_nav_down(&mut self) {
if !self.tasks.is_empty() {
if let Some(selected) = self.tasks_list_state.selected() {
let next = if selected >= self.tasks.len().saturating_sub(1) {
0 // Wrap to top
} else {
selected + 1
};
self.tasks_list_state.select(Some(next));
}
self.dirty = true;
}
}
/// Move task selection up one position
///
/// # Behavior
/// - Wraps around to bottom when at top
/// - Only works if tasks list is not empty
/// - Sets `dirty = true`
/// # R-LIST-STATE Pattern
/// Uses ListState for proper selection
pub(crate) fn task_nav_up(&mut self) {
if !self.tasks.is_empty() {
if let Some(selected) = self.tasks_list_state.selected() {
let prev = if selected == 0 {
self.tasks.len().saturating_sub(1) // Wrap to bottom
} else {
selected - 1
};
self.tasks_list_state.select(Some(prev));
}
self.dirty = true;
}
}
/// Toggle the completion status of the selected task
///
/// # Behavior
/// - Toggles `completed` status of the selected task
/// - Marks `dirty = true` to trigger redraw
/// - Does nothing if no task is selected
/// # R-LIST-STATE Pattern
/// Uses ListState for proper selection
pub(crate) fn toggle_task_completion(&mut self) {
if let Some(selected) = self.tasks_list_state.selected() {
if let Some(task) = self.tasks.get_mut(selected) {
task.completed = !task.completed;
self.dirty = true;
// Note: In a full implementation, this would also call the API to persist the change
}
}
}
/// Go back from tasks screen to project detail
///
/// # Dirty Flag
/// Sets `dirty = true` when screen changes
pub(crate) fn back_to_project_detail(&mut self) {
self.screen = Screen::ProjectDetail;
self.tasks.clear();
self.tasks_list_state.select(Some(0));
self.dirty = true;
}
/// Navigate to task creation screen
///
/// # Dirty Flag
/// Sets `dirty = true` when screen changes
pub(crate) fn create_task_screen(&mut self) {
self.screen = Screen::CreateTask;
self.task_textarea = TextArea::default();
self.task_creation_mode = true;
self.task_form_error = None;
self.dirty = true;
}
/// Validate task description with comprehensive checks
///
/// # Validation Rules
/// - Content cannot be empty or whitespace only
/// - Minimum 3 characters (trimmed)
/// - Maximum 500 characters (including newlines)
/// - First line cannot be empty or whitespace only
///
/// # Errors
/// Returns specific error messages for each validation failure
pub(crate) fn validate_task_description(&self) -> Result<(), String> {
let content = self.task_textarea.lines().join("\n");
let trimmed = content.trim();
// Check for empty input
if trimmed.is_empty() {
return Err("Please enter a task description (at least 3 characters)".to_string());
}
// Check for minimum length (based on trimmed content)
if trimmed.len() < 3 {
return Err(format!(
"Task description must be at least 3 characters (currently {} chars)",
trimmed.len()
));
}
// Check for maximum length (allow more for multi-line descriptions)
if content.len() > 500 {
return Err(format!(
"Task description is too long ({} chars, maximum is 500)",
content.len()
));
}
// Check that first line is not just whitespace
if let Some(first_line) = self.task_textarea.lines().first() {
if first_line.trim().is_empty() {
return Err("First line of task cannot be empty".to_string());
}
}
Ok(())
}
/// Go back from task creation to tasks screen
///
/// # Dirty Flag
/// Sets `dirty = true` when screen changes
pub(crate) fn back_to_tasks(&mut self) {
self.screen = Screen::Tasks;
self.task_textarea = TextArea::default();
self.task_form_error = None;
self.dirty = true;
}
// === Task Filtering/Search Methods ===
/// Get filtered and sorted tasks
///
/// Applies search query, completion filter, and sort order to the task list.
/// This is a read-only method that doesn't modify state.
pub(crate) fn filtered_tasks(&self) -> Vec<ChecklistItemDto> {
let mut filtered: Vec<_> = self
.tasks
.iter()
.filter(|task| {
// Apply completion filter
match self.task_filter_completed {
Some(true) => task.completed,
Some(false) => !task.completed,
None => true, // Show all
}
})
.filter(|task| {
// Apply search query
if self.task_search_query.is_empty() {
return true;
}
let query = self.task_search_query.to_lowercase();
task.description.to_lowercase().contains(&query)
|| task.id.to_lowercase().contains(&query)
})
.cloned()
.collect();
// Apply sorting
match self.task_sort {
TaskSortOption::CreatedNewest => {
filtered.sort_by(|a, b| b.created_at.cmp(&a.created_at));
}
TaskSortOption::CreatedOldest => {
filtered.sort_by(|a, b| a.created_at.cmp(&b.created_at));
}
TaskSortOption::PriorityHighest => {
filtered.sort_by(|a, b| {
let priority_order = |p: &TaskPriority| match p {
TaskPriority::Critical => 0,
TaskPriority::High => 1,
TaskPriority::Medium => 2,
TaskPriority::Low => 3,
};
priority_order(&a.priority).cmp(&priority_order(&b.priority))
});
}
TaskSortOption::PriorityLowest => {
filtered.sort_by(|a, b| {
let priority_order = |p: &TaskPriority| match p {
TaskPriority::Critical => 0,
TaskPriority::High => 1,
TaskPriority::Medium => 2,
TaskPriority::Low => 3,
};
priority_order(&b.priority).cmp(&priority_order(&a.priority))
});
}
TaskSortOption::CompletedFirst => {
filtered.sort_by(|a, b| b.completed.cmp(&a.completed));
}
TaskSortOption::PendingFirst => {
filtered.sort_by(|a, b| a.completed.cmp(&b.completed));
}
}
filtered
}
/// Calculate task statistics from all tasks
///
/// Returns (total, completed, pending, completion_percentage)
pub(crate) fn calculate_statistics(&self) -> (usize, usize, usize, f64) {
let total = self.tasks.len();
let completed = self.tasks.iter().filter(|t| t.completed).count();
let pending = total - completed;
let completion_percentage = if total > 0 {
(completed as f64 / total as f64) * 100.0
} else {
0.0
};
(total, completed, pending, completion_percentage)
}
/// Toggle filter view to filter/sort screen
///
/// # Dirty Flag
/// Sets `dirty = true` when screen changes
pub(crate) fn show_filters(&mut self) {
self.screen = Screen::TasksFilter;
self.dirty = true;
}
/// Return from filter screen to tasks screen
///
/// # Dirty Flag
/// Sets `dirty = true` when screen changes
pub(crate) fn back_to_task_view(&mut self) {
self.screen = Screen::Tasks;
self.dirty = true;
}
/// Toggle completion filter (all/completed/pending)
pub(crate) fn toggle_completion_filter(&mut self) {
self.task_filter_completed = match self.task_filter_completed {
None => Some(true), // all → completed
Some(true) => Some(false), // completed → pending
Some(false) => None, // pending → all
};
self.dirty = true;
}
/// Clear all filters (show all tasks in default sort order)
pub(crate) fn clear_all_filters(&mut self) {
self.task_search_query.clear();
self.task_filter_completed = None;
self.task_sort = TaskSortOption::default();
self.dirty = true;
}
/// Add character to search query
pub(crate) fn add_search_char(&mut self, c: char) {
self.task_search_query.push(c);
self.dirty = true;
}
/// Remove last character from search query
pub(crate) fn backspace_search(&mut self) {
self.task_search_query.pop();
self.dirty = true;
}
/// Navigate to next sort option
pub(crate) fn next_sort_option(&mut self) {
let max = TaskSortOption::all().len();
self.filter_sort_selected = (self.filter_sort_selected + 1) % max;
self.dirty = true;
}
/// Navigate to previous sort option
pub(crate) fn prev_sort_option(&mut self) {
let max = TaskSortOption::all().len();
self.filter_sort_selected = if self.filter_sort_selected == 0 {
max - 1
} else {
self.filter_sort_selected - 1
};
self.dirty = true;
}
/// Apply selected sort option
pub(crate) fn apply_sort_option(&mut self) {
if let Some(&sort) = TaskSortOption::all().get(self.filter_sort_selected) {
self.task_sort = sort;
self.dirty = true;
}
}
// === Error Handling ===
/// Clear the current error message
///
/// # Dirty Flag
/// Sets `dirty = true` if error was present
#[allow(dead_code)]
pub(crate) fn clear_error(&mut self) {
if self.error.is_some() {
self.error = None;
self.dirty = true;
}
}
// === Form Management ===
/// Reset create project form to empty state
pub(crate) fn reset_create_project_form(&mut self) {
self.project_name_input.clear();
self.project_desc_input.clear();
}
/// Update project name input
#[allow(dead_code)]
pub(crate) fn set_project_name(&mut self, name: String) {
self.project_name_input = name;
self.dirty = true;
}
/// Update project description input
#[allow(dead_code)]
pub(crate) fn set_project_description(&mut self, desc: String) {
self.project_desc_input = desc;
self.dirty = true;
}
// === Application Lifecycle ===
/// Request application quit
pub(crate) fn quit(&mut self) {
self.should_quit = true;
}
/// Mark state as rendered (clear dirty flag)
///
/// # Usage
/// Call this after successfully rendering, in main event loop:
/// ```no_run
/// if app.dirty {
/// terminal.draw(|f| ui::render(f, &app))?;
/// app.mark_rendered(); // Clear dirty flag
/// }
/// ```
pub(crate) fn mark_rendered(&mut self) {
self.dirty = false;
}
// === Task Editing Methods ===
/// Get the currently selected task
pub(crate) fn selected_task(&self) -> Option<&ChecklistItemDto> {
if let Some(selected) = self.tasks_list_state.selected() {
self.tasks.get(selected)
} else {
None
}
}
/// Start editing the currently selected task
pub(crate) fn start_edit_task(&mut self) {
if let Some(task) = self.selected_task() {
let description = task.description.clone();
let task_id = task.id.clone();
self.task_textarea = TextArea::from(vec![description.as_str()]);
self.editing_task_id = Some(task_id);
self.task_creation_mode = false;
self.task_form_error = None;
self.screen = Screen::EditTask;
self.dirty = true;
}
}
/// Submit task edit to API
pub(crate) async fn submit_task_edit(&mut self) -> Result<()> {
if let Some(task_id) = self.editing_task_id.take() {
let description = self.task_textarea.lines().join("\n");
// Validate description
if let Err(e) = self.validate_task_description() {
self.task_form_error = Some(e);
self.editing_task_id = Some(task_id); // Restore ID
self.dirty = true;
return Err(anyhow::anyhow!("Validation failed"));
}
// Update via API (placeholder - implement when API method exists)
// self.api.update_task(&task_id, &description).await?;
// Update local state
if let Some(task) = self.tasks.iter_mut().find(|t| t.id == task_id) {
task.description = description;
}
self.screen = Screen::Tasks;
self.task_form_error = None;
self.dirty = true;
Ok(())
} else {
Err(anyhow::anyhow!("No task being edited"))
}
}
/// Cancel task editing
pub(crate) fn cancel_edit_task(&mut self) {
self.screen = Screen::Tasks;
self.editing_task_id = None;
self.task_textarea = TextArea::default();
self.task_form_error = None;
self.dirty = true;
}
// === Task Deletion Methods ===
/// Start task deletion flow (mode-dependent)
pub(crate) fn start_delete_task(&mut self) {
if self.selected_task().is_none() {
return; // No task selected
}
match self.delete_confirmation_mode {
DeleteMode::Modal => {
self.screen = Screen::DeleteConfirm;
self.dirty = true;
}
DeleteMode::Inline => {
if self.delete_confirm_pending {
// Second press - actually delete
// This will be handled by async operation
self.delete_confirm_pending = false;
} else {
// First press - mark pending
self.delete_confirm_pending = true;
self.dirty = true;
}
}
DeleteMode::Prompt => {
self.awaiting_delete_confirm = true;
self.dirty = true;
}
}
}
/// Confirm and execute task deletion
pub(crate) async fn confirm_delete_task(&mut self) -> Result<()> {
if let Some(task) = self.selected_task() {
let task_id = task.id.clone();
// Delete via API (placeholder - implement when API method exists)
// self.api.delete_task(&task_id).await?;
// Remove from local state
self.tasks.retain(|t| t.id != task_id);
// Reset delete state
self.delete_confirm_pending = false;
self.awaiting_delete_confirm = false;
// Return to tasks screen
if self.screen == Screen::DeleteConfirm {
self.screen = Screen::Tasks;
}
self.dirty = true;
Ok(())
} else {
Err(anyhow::anyhow!("No task selected"))
}
}
/// Cancel task deletion
pub(crate) fn cancel_delete_task(&mut self) {
self.delete_confirm_pending = false;
self.awaiting_delete_confirm = false;
if self.screen == Screen::DeleteConfirm {
self.screen = Screen::Tasks;
}
self.dirty = true;
}
// === Help Screen Methods ===
/// Show help screen
pub(crate) fn show_help(&mut self) {
self.screen = Screen::Help;
self.help_scroll_offset = 0;
self.dirty = true;
}
/// Close help screen
pub(crate) fn close_help(&mut self) {
self.screen = Screen::Tasks; // Or previous screen
self.dirty = true;
}
/// Scroll help screen down
pub(crate) fn help_scroll_down(&mut self) {
self.help_scroll_offset = self.help_scroll_offset.saturating_add(1);
self.dirty = true;
}
/// Scroll help screen up
pub(crate) fn help_scroll_up(&mut self) {
self.help_scroll_offset = self.help_scroll_offset.saturating_sub(1);
self.dirty = true;
}
// === Theme Selector Methods ===
/// Show theme selector
pub(crate) fn show_theme_selector(&mut self) {
self.screen = Screen::ThemeSelector;
self.dirty = true;
}
/// Close theme selector
pub(crate) fn close_theme_selector(&mut self) {
self.screen = Screen::Tasks; // Or previous screen
self.dirty = true;
}
/// Navigate theme selector down
pub(crate) fn theme_nav_down(&mut self) {
let max_themes = 5; // default, dark, light, minimal, professional
self.theme_selector_index = (self.theme_selector_index + 1) % max_themes;
self.dirty = true;
}
/// Navigate theme selector up
pub(crate) fn theme_nav_up(&mut self) {
let max_themes = 5;
self.theme_selector_index = if self.theme_selector_index == 0 {
max_themes - 1
} else {
self.theme_selector_index - 1
};
self.dirty = true;
}
/// Apply selected theme
pub(crate) fn apply_selected_theme(&mut self) -> Result<()> {
let themes = ["default", "dark", "light", "minimal", "professional"];
if let Some(&theme_name) = themes.get(self.theme_selector_index) {
self.current_theme = theme_name.to_string();
self.close_theme_selector();
// TODO: Save to config file
Ok(())
} else {
Err(anyhow::anyhow!("Invalid theme index"))
}
}
/// Toggle dark mode (quick switch)
#[allow(dead_code)]
pub(crate) fn toggle_dark_mode(&mut self) {
self.current_theme = if self.current_theme == "dark" {
"default".to_string()
} else {
"dark".to_string()
};
self.dirty = true;
}
// === Layout Methods ===
/// Save current layout to file
#[allow(dead_code)]
pub(crate) fn save_layout(&self) -> Result<()> {
use crate::config::LayoutConfig;
let layout = LayoutConfig {
task_detail_width_percent: self.task_detail_width_percent,
window_width: 0, // Will be set by terminal size
window_height: 0,
restore_on_startup: true,
};
layout
.save()
.map_err(|e| anyhow::anyhow!("Failed to save layout: {}", e))
}
/// Restore layout from file
pub(crate) fn restore_layout(&mut self) -> Result<()> {
use crate::config::LayoutConfig;
match LayoutConfig::load() {
Ok(layout) => {
self.task_detail_width_percent = layout.task_detail_width_percent;
self.dirty = true;
Ok(())
}
Err(e) => {
tracing::warn!("Failed to restore layout: {}", e);
Ok(()) // Don't fail, just use defaults
}
}
}
/// Reset layout to defaults
#[allow(dead_code)]
pub(crate) fn reset_layout(&mut self) {
self.task_detail_width_percent = 50;
self.dirty = true;
}
// === Pagination Methods ===
/// Load more tasks (next page)
pub(crate) async fn load_more_tasks(&mut self) -> Result<()> {
if self.current_page * self.tasks_per_page < self.total_tasks {
self.current_page += 1;
// TODO: Implement server-side pagination
// For now, just mark as loading
self.is_loading = true;
self.dirty = true;
}
Ok(())
}
/// Get pagination info string
#[allow(dead_code)]
pub(crate) fn pagination_info(&self) -> String {
let start = self.current_page * self.tasks_per_page + 1;
let end = ((self.current_page + 1) * self.tasks_per_page).min(self.total_tasks);
format!("Showing {}-{} of {} tasks", start, end, self.total_tasks)
}
// === Configuration Methods ===
/// Load configuration from app config
pub(crate) fn load_from_config(&mut self, config: &crate::config::TuiConfig) {
use crate::app::DeleteMode;
self.delete_confirmation_mode = DeleteMode::from_str(&config.ui.delete_confirmation_mode);
self.current_theme = config.app.theme.clone();
// Restore layout if configured
if let Err(e) = self.restore_layout() {
tracing::warn!("Failed to restore layout: {}", e);
}
self.dirty = true;
}
}
impl Default for App {
fn default() -> Self {
Self::new()
}
}
// ============================================================================
// TESTS - R-STATE-SEPARATION pattern validation
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
/// Helper function to create a test ProjectDto
fn create_test_project(id: &str, name: &str, phase: &str) -> ProjectDto {
ProjectDto {
id: id.to_string(),
name: name.to_string(),
current_phase: phase.to_string(),
version: "1.0".to_string(),
project_type: "Web".to_string(),
description: "Test project".to_string(),
created_at: "2024-01-01".to_string(),
updated_at: "2024-01-01".to_string(),
}
}
// === APP INITIALIZATION TESTS ===
#[test]
fn test_app_new_default_state() {
let app = App::new();
assert_eq!(app.screen, Screen::ProjectsList);
assert!(app.dirty); // Should start dirty to force initial render
assert!(app.projects.is_empty());
assert_eq!(app.projects_list_state.selected(), Some(0)); // ListState starts with first item selected
assert!(app.current_project.is_none());
assert!(!app.should_quit);
assert!(app.error.is_none());
}
#[test]
fn test_app_default_trait() {
let app = App::default();
let new_app = App::new();
assert_eq!(app.screen, new_app.screen);
assert_eq!(app.dirty, new_app.dirty);
assert_eq!(app.should_quit, new_app.should_quit);
}
// === NAVIGATION TESTS ===
#[test]
fn test_nav_down_single_item() {
let mut app = App::new();
app.projects = vec![create_test_project("1", "Project 1", "Phase 1")];
app.dirty = false;
app.nav_down();
// Should wrap around when at single item
assert_eq!(app.projects_list_state.selected(), Some(0));
assert!(app.dirty); // Dirty flag should be set
}
#[test]
fn test_nav_down_multiple_items() {
let mut app = App::new();
app.projects = vec![
create_test_project("1", "Project 1", "Phase 1"),
create_test_project("2", "Project 2", "Phase 2"),
create_test_project("3", "Project 3", "Phase 3"),
];
app.dirty = false;
app.projects_list_state.select(Some(0));
app.nav_down();
assert_eq!(app.projects_list_state.selected(), Some(1));
assert!(app.dirty);
app.dirty = false;
app.nav_down();
assert_eq!(app.projects_list_state.selected(), Some(2));
assert!(app.dirty);
// Test wrapping
app.dirty = false;
app.nav_down();
assert_eq!(app.projects_list_state.selected(), Some(0)); // Should wrap to start
assert!(app.dirty);
}
#[test]
fn test_nav_up_wrapping() {
let mut app = App::new();
app.projects = vec![
create_test_project("1", "P1", "Phase"),
create_test_project("2", "P2", "Phase"),
];
app.projects_list_state.select(Some(0));
app.dirty = false;
app.nav_up();
// Should wrap to end when at start
assert_eq!(app.projects_list_state.selected(), Some(1));
assert!(app.dirty);
}
// === SCREEN NAVIGATION TESTS ===
#[test]
fn test_select_project() {
let mut app = App::new();
app.projects = vec![create_test_project("1", "Project 1", "Phase 1")];
app.projects_list_state.select(Some(0));
app.dirty = false;
app.select_project();
assert_eq!(app.screen, Screen::ProjectDetail);
assert!(app.current_project.is_some());
assert_eq!(app.current_project.as_ref().unwrap().id, "1");
assert!(app.dirty);
}
#[test]
fn test_back_to_projects() {
let mut app = App::new();
app.screen = Screen::ProjectDetail;
app.current_project = Some(create_test_project("1", "Project 1", "Phase 1"));
app.dirty = false;
app.back_to_projects();
assert_eq!(app.screen, Screen::ProjectsList);
assert!(app.current_project.is_none());
assert!(app.dirty);
}
#[test]
fn test_create_project_screen() {
let mut app = App::new();
app.project_name_input = "Old Name".to_string();
app.project_desc_input = "Old Desc".to_string();
app.dirty = false;
app.create_project_screen();
assert_eq!(app.screen, Screen::CreateProject);
assert!(app.project_name_input.is_empty()); // Form should be reset
assert!(app.project_desc_input.is_empty());
assert!(app.dirty);
}
#[test]
fn test_view_security() {
let mut app = App::new();
app.dirty = false;
app.view_security();
assert_eq!(app.screen, Screen::Security);
assert!(app.dirty);
}
// === ERROR HANDLING TESTS ===
#[test]
fn test_clear_error_when_present() {
let mut app = App::new();
app.error = Some("Error message".to_string());
app.dirty = false;
app.clear_error();
assert!(app.error.is_none());
assert!(app.dirty);
}
#[test]
fn test_clear_error_when_not_present() {
let mut app = App::new();
app.error = None;
app.dirty = false;
app.clear_error();
assert!(app.error.is_none());
// Dirty should remain false if no error was present
assert!(!app.dirty);
}
// === FORM INPUT TESTS ===
#[test]
fn test_set_project_name() {
let mut app = App::new();
app.dirty = false;
app.set_project_name("New Project".to_string());
assert_eq!(app.project_name_input, "New Project");
assert!(app.dirty);
}
#[test]
fn test_set_project_description() {
let mut app = App::new();
app.dirty = false;
app.set_project_description("New Description".to_string());
assert_eq!(app.project_desc_input, "New Description");
assert!(app.dirty);
}
#[test]
fn test_reset_create_project_form() {
let mut app = App::new();
app.project_name_input = "Name".to_string();
app.project_desc_input = "Desc".to_string();
app.reset_create_project_form();
assert!(app.project_name_input.is_empty());
assert!(app.project_desc_input.is_empty());
}
// === DIRTY FLAG TESTS ===
#[test]
fn test_mark_rendered_clears_dirty() {
let mut app = App::new();
assert!(app.dirty); // Starts dirty
app.mark_rendered();
assert!(!app.dirty);
}
#[test]
fn test_dirty_flag_set_on_mutations() {
let mut app = App::new();
app.projects = vec![create_test_project("1", "P1", "Phase")];
app.dirty = false;
// Each mutation should set dirty
app.nav_down();
assert!(app.dirty);
app.dirty = false;
app.nav_up();
assert!(app.dirty);
app.dirty = false;
app.create_project_screen();
assert!(app.dirty);
app.dirty = false;
app.view_security();
assert!(app.dirty);
}
// === APPLICATION LIFECYCLE TESTS ===
#[test]
fn test_quit() {
let mut app = App::new();
assert!(!app.should_quit);
app.quit();
assert!(app.should_quit);
}
// === EDGE CASES ===
#[test]
fn test_nav_empty_projects() {
let mut app = App::new();
assert!(app.projects.is_empty());
app.dirty = false;
app.nav_down();
// Should not panic, dirty should remain false
assert!(!app.dirty);
assert_eq!(app.projects_list_state.selected(), Some(0));
}
#[test]
fn test_select_project_empty_list() {
let mut app = App::new();
assert!(app.projects.is_empty());
app.dirty = false;
app.select_project();
// Should not panic, screen should not change
assert_eq!(app.screen, Screen::ProjectsList);
assert!(app.current_project.is_none());
assert!(!app.dirty);
}
#[test]
fn test_multiple_state_changes() {
let mut app = App::new();
app.projects = vec![create_test_project("1", "P1", "Phase")];
// Simulate user interaction sequence
app.nav_down();
assert!(app.dirty);
app.mark_rendered();
app.select_project();
assert!(app.dirty);
assert_eq!(app.screen, Screen::ProjectDetail);
app.mark_rendered();
app.back_to_projects();
assert!(app.dirty);
assert_eq!(app.screen, Screen::ProjectsList);
}
}