1433 lines
44 KiB
Rust
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);
|
||
|
|
}
|
||
|
|
}
|