tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
#[allow(clippy::wildcard_imports)]
use super::*;

/// Why an advertised TUI action could not run, and what would make it possible.
///
/// The pair travels together so a refusal cannot regress into a bare "nothing
/// happened" or a reason with no next step. The active mode renders both lines.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ActionFeedback {
    /// The action named in the user's own vocabulary.
    pub action: String,
    /// Why this action cannot run in the current state.
    pub reason: String,
    /// What the user can do to make the action possible.
    pub remedy: String,
    /// The legacy plain-status spelling some surfaces still consume. The
    /// two-line notice always uses `reason` and `remedy`.
    pub status: Option<String>,
}

impl ActionFeedback {
    pub(in crate::app) fn new(
        action: &str,
        reason: impl Into<String>,
        remedy: impl Into<String>,
    ) -> Self {
        Self {
            action: action.to_string(),
            reason: reason.into(),
            remedy: remedy.into(),
            status: None,
        }
    }

    fn with_status(mut self, status: impl Into<String>) -> Self {
        self.status = Some(status.into());
        self
    }

    /// The first notice line.
    pub(in crate::app) fn reason_line(&self) -> String {
        format!("{} unavailable: {}", self.action, self.reason)
    }

    /// The remedy line.
    pub(in crate::app) fn remedy_line(&self) -> String {
        format!("remedy: {}", self.remedy)
    }
}

impl App {
    /// Refuse `action` before dispatch when the current mode makes it impossible.
    ///
    /// This is the empty/ineligible half of the contract in tix #1643. Service
    /// errors and prompts that already explain themselves keep their own
    /// wording; this guard covers paths that previously returned without
    /// changing anything.
    #[allow(clippy::too_many_lines)]
    pub(in crate::app) fn action_refusal(
        &self,
        context: InputContext,
        action: TuiAction,
    ) -> Option<ActionFeedback> {
        use TuiAction::{
            Agent, Delete, DeletePlanningItem, Edit, EditPlanningItem, MoveColumnDown,
            MoveColumnUp, MoveDown, MoveLeft, MoveNext, MovePrevious, MoveRight, MoveUp, Open,
            OpenPicker, PreviousBoard, ReorderDown, ReorderUp, ScrollDown, ScrollUp, Submit,
            TimeAdd, TimeBreakdown, TimeDelete, TimeEdit, TimeFilter, TimeHistory, Toggle, View,
        };

        let unavailable = |reason: &str, remedy: &str| {
            Some(ActionFeedback::new(
                action.description_in(context),
                reason,
                remedy,
            ))
        };
        let empty_list = |noun: &str, remedy: &str| {
            unavailable(&format!("there are no {noun} to act on"), remedy)
        };
        let needs_selection = || {
            unavailable(
                "no issue is selected",
                "move the cursor onto an issue, then try again",
            )
        };

        match context {
            InputContext::Board
                if matches!(
                    action,
                    Open | Edit
                        | View
                        | Agent
                        | Delete
                        | MovePrevious
                        | MoveNext
                        | ReorderUp
                        | ReorderDown
                ) && self.board.get_selected_card().is_none() =>
            {
                needs_selection()
            }
            InputContext::Board
                if matches!(
                    action,
                    MoveUp | MoveDown | ScrollUp | ScrollDown | TuiAction::Mark
                ) && self.board.column_cards(self.board.active_col).is_empty() =>
            {
                unavailable(
                    "the visible board column has no issues",
                    "add an issue with n or clear the filter with f",
                )
            }
            InputContext::Form if action == OpenPicker => {
                let focus = self.form.as_ref()?.focus;
                if focus.is_target() {
                    unavailable(
                        "the Target row is a toggle, not a picker",
                        "press Enter to switch between Triage and Todo",
                    )
                } else if matches!(
                    focus,
                    crate::ui::form::FormFocus::Title
                        | crate::ui::form::FormFocus::Estimate
                        | crate::ui::form::FormFocus::Body
                ) {
                    unavailable(
                        &format!("the {} field is typed, not picked", focus.label()),
                        "focus a chosen field, or press Enter where the field has a picker",
                    )
                } else {
                    None
                }
            }
            InputContext::Form if action == Submit => {
                let form = self.form.as_ref()?;
                let enter_does_something_else = form.focus.is_target()
                    || form.focus.is_template()
                    || form.focus.is_status()
                    || form.focus.is_priority()
                    || (form.focus.is_date() && !form.focused_date_is_complete())
                    || (form.focus.is_parent() && form.parent.trim().is_empty());
                (form.title.trim().is_empty() && !enter_does_something_else).then(|| {
                    ActionFeedback::new(
                        action.description_in(context),
                        "the Title field is empty",
                        "type a title, then press Enter again",
                    )
                    .with_status("Title is required")
                })
            }
            InputContext::Triage
                if matches!(action, MoveUp | MoveDown | Submit | TuiAction::Toggle)
                    && self
                        .triage
                        .as_ref()
                        .is_none_or(|state| state.rows.is_empty()) =>
            {
                unavailable(
                    "the triage inbox has no issues",
                    "press n to create one in the entry column, or q to return to the board",
                )
            }
            InputContext::Cycle if matches!(action, MoveUp | MoveDown | MoveLeft | MoveRight) => {
                let rows = self.cycle_entry_count();
                let selected = self.cycle_state.as_ref().map_or(0, |state| state.selected);
                let groups = self.cycle_groups();
                let populated_groups = groups
                    .iter()
                    .filter(|group| !group.entries.is_empty())
                    .count();
                let at_vertical_bound = match action {
                    MoveUp => selected == 0,
                    MoveDown => rows == 0 || selected + 1 >= rows,
                    MoveLeft | MoveRight => populated_groups <= 1,
                    _ => false,
                };
                at_vertical_bound.then(|| {
                    ActionFeedback::new(
                        action.description_in(context),
                        if rows == 0 {
                            "there are no cycle rows".to_string()
                        } else {
                            "the cursor is already at the edge of the cycle listing".to_string()
                        },
                        "press m to define another cycle, or move in the other direction",
                    )
                })
            }
            InputContext::Milestones
                if matches!(
                    action,
                    MoveUp | MoveDown | Submit | EditPlanningItem | DeletePlanningItem
                ) && self.planning_rows_len() == 0 =>
            {
                empty_list(
                    "milestones",
                    "press n to define one, or q to return to the board",
                )
            }
            InputContext::Repos
                if matches!(action, MoveUp | MoveDown | Submit)
                    && self.repo_entries().is_empty() =>
            {
                empty_list(
                    "repositories",
                    "add a `[repos]` entry to tix.toml, then reopen Repos",
                )
            }
            InputContext::Stats => match self.stats_tab {
                StatsTab::Health => {
                    if matches!(action, MoveUp | MoveDown | Submit) && self.stats_rows().is_empty()
                    {
                        return empty_list(
                            "health scopes",
                            "record issue health or add an issue, then reopen Stats",
                        );
                    }
                    if matches!(
                        action,
                        TimeAdd | TimeEdit | TimeDelete | TimeFilter | TimeBreakdown | TimeHistory
                    ) {
                        return unavailable(
                            "the Time controls are on the Time tab",
                            "press Tab until the Time tab is active",
                        );
                    }
                    None
                }
                StatsTab::Time => {
                    let rows = self
                        .stats_time
                        .as_ref()
                        .map_or(0, |state| state.rows().len());
                    if matches!(action, MoveUp | MoveDown | Submit | TimeEdit | TimeDelete)
                        && rows == 0
                    {
                        return empty_list(
                            "time entries",
                            "press a to add an entry, or Tab to choose another tab",
                        );
                    }
                    None
                }
                StatsTab::Burndown => None,
            },
            InputContext::Schedule if matches!(action, MoveUp | MoveDown) => {
                let tab = self
                    .schedule
                    .as_ref()
                    .map_or(crate::ui::schedule::ScheduleTab::Series, |state| state.tab);
                let rows = match tab {
                    crate::ui::schedule::ScheduleTab::Series => self.schedule_entries().len(),
                    crate::ui::schedule::ScheduleTab::Cycles => self.cycle_schedule_entries().len(),
                    crate::ui::schedule::ScheduleTab::Capacity => self.capacity_rows().len(),
                };
                (rows == 0).then(|| {
                    let remedy = match tab {
                        crate::ui::schedule::ScheduleTab::Series => {
                            "press n to define a series, then try again"
                        }
                        crate::ui::schedule::ScheduleTab::Cycles => {
                            "press n to define a cycle schedule, then try again"
                        }
                        crate::ui::schedule::ScheduleTab::Capacity => {
                            "configure a capacity scope, then reopen Schedule"
                        }
                    };
                    ActionFeedback::new(
                        action.description_in(context),
                        format!("the {} tab has no rows", tab.label()),
                        remedy,
                    )
                })
            }
            InputContext::Portfolio => {
                let tab = self
                    .portfolio
                    .as_ref()
                    .map_or(crate::ui::portfolio::PortfolioTab::Projects, |state| {
                        state.tab
                    });
                let rows = match tab {
                    crate::ui::portfolio::PortfolioTab::Projects => self.projects_snapshot().len(),
                    crate::ui::portfolio::PortfolioTab::Epics => self.portfolio_epics().len(),
                    crate::ui::portfolio::PortfolioTab::Roadmap => self
                        .portfolio_roadmap()
                        .map_or(0, |projection| projection.initiatives.len()),
                };
                if matches!(action, MoveUp | MoveDown) && rows == 0 {
                    return empty_list(
                        "portfolio rows",
                        "press Tab to choose another tab, or define a record with tix",
                    );
                }
                if matches!(action, MoveLeft | MoveRight) {
                    let members = self
                        .portfolio_epics()
                        .get(self.portfolio_state().selected)
                        .map_or(0, |entry| entry.members.len());
                    if members == 0 {
                        return empty_list(
                            "epic members",
                            "press Tab until Epics is active and select an epic with members",
                        );
                    }
                }
                if action == Submit && tab != crate::ui::portfolio::PortfolioTab::Epics {
                    return unavailable(
                        "only an epic member can be opened directly",
                        "press Tab until Epics is active, then select a member",
                    );
                }
                None
            }
            InputContext::Library if matches!(action, MoveUp | MoveDown | Submit) => {
                let tab = self
                    .library
                    .as_ref()
                    .map_or(crate::ui::library::LibraryTab::Views, |state| state.tab);
                self.library_entries(tab).is_empty().then(|| {
                    ActionFeedback::new(
                        action.description_in(context),
                        format!("the {} tab has no rows", tab.label()),
                        "press Tab to choose another tab, or create a view with tix view create",
                    )
                })
            }
            InputContext::Settings => {
                let boards = self.store.config.configs().count();
                let section = self
                    .settings
                    .as_ref()
                    .map_or(crate::ui::settings::ConfigSection::Dashboards, |modal| {
                        modal.section
                    });
                if action == Delete
                    && section == crate::ui::settings::ConfigSection::Dashboards
                    && boards <= 1
                {
                    return unavailable(
                        "this is the last board",
                        "press n to add another board first",
                    )
                    .map(|feedback| feedback.with_status("Cannot delete the last board"));
                }
                if matches!(action, PreviousBoard | TuiAction::NextBoard) && boards <= 1 {
                    return unavailable(
                        "there is only one configured board",
                        "press n to add another board, then switch again",
                    );
                }
                let level = self
                    .settings
                    .as_ref()
                    .map_or(crate::ui::settings::ConfigLevel::Sections, |modal| {
                        modal.level
                    });
                if matches!(action, MoveColumnUp | MoveColumnDown) {
                    let columns = self.active_board_config().columns.len();
                    if level != crate::ui::settings::ConfigLevel::Detail || columns < 2 {
                        return unavailable(
                            "column order can only change in a board with at least two columns",
                            "open the board detail and add a second column first",
                        );
                    }
                }
                if action == Toggle && level != crate::ui::settings::ConfigLevel::Detail {
                    return unavailable(
                        "this toggle acts on a board-detail row",
                        "press l or Enter to open the selected board's detail",
                    );
                }
                None
            }
            _ => None,
        }
    }
}