tix-tracker 0.11.0

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

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
    None,
    Quit,
    OpenEditor(String),
    /// Open the issue file in the resolved read-only viewer (tix #1507).
    OpenViewer(String),
    /// Open an **arbitrary** file in the resolved read-only viewer (tix #1513).
    ///
    /// Distinct from [`Action::OpenViewer`], which resolves a store-relative
    /// filename through the store: a `related_issues` *path* entry may point
    /// anywhere, including outside the store, and there is no store entry to look
    /// up. The path is absolute — it comes from [`crate::related::resolve_path`].
    OpenViewerAt(PathBuf),
    /// Run `tix` in a child (or parent) repository, with the working directory
    /// set to it (tix #1529). Handled in the event loop because it hands the
    /// terminal to the child, exactly as [`Action::OpenEditor`] does; on exit the
    /// parent TUI is restored, so the jump is inherently reversible.
    OpenRepo(PathBuf),
    /// Re-open the editor on the conflict prompt's temp copy so the user can
    /// compare it with the newer on-disk version (tix #1510). Handled in the
    /// event loop because it hands the terminal to the child, exactly as
    /// [`Action::OpenEditor`] does.
    CompareEdit,
    /// Re-open the editor on the repair prompt's retained copy (tix #1506), so a
    /// problem with no automatic fix can be corrected by hand. Handled in the
    /// event loop for the same reason [`Action::CompareEdit`] is: the child needs
    /// the terminal.
    ReeditRepair,
    OpenAgent(String),
    /// Switch to a named board by tab index (mouse click on a tab, D5/D6).
    SwitchBoard(usize),
    /// Confirm-delete dialog Yes/No button clicked; maps to the `y` /
    /// any-other-key branch of [`App::handle_confirm_delete_key`] (D6).
    ConfirmDeleteYes,
    ConfirmDeleteNo,
    /// A filter-modal criterion row was clicked; focus it and open its picker or,
    /// on a date row, the calendar (D6).
    FilterModalFocus(FilterField),
    /// A visible option row of the filter modal's open picker was clicked; choose
    /// it by its position in the filtered option list.
    FilterPickerOption(usize),
    /// A click landed outside the filter modal's open picker; close it.
    FilterPickerClose,
    /// A settings-modal row was clicked; select it and run the Enter action.
    SettingsRow(usize),
    /// A dashboards **detail** row was clicked; select it and run the Enter
    /// action (tui-config-mode).
    ConfigDetailRow(usize),
    /// A date-picker day cell or control was clicked; maps to the equivalent
    /// key branch (D6): day → select that date, Prev/Next → page the month,
    /// Submit → Enter.
    DatePickerHit(DatePickerHit),
    /// The help overlay was clicked; close it like the `?`/`q`/Esc branch.
    HelpClose,
    /// A vertical mode-navigation entry was clicked; switch the shell to that
    /// top-level mode (tui-mode-navigation).
    SwitchMode(TopMode),
    /// The Triage mode's status control was clicked; open the status picker
    /// (tui-triage-mode).
    TriageOpenStatus,
    /// The Triage mode's assignee control was clicked; open the assignee picker.
    TriageOpenAssignee,
    /// The Triage mode's create control was clicked; start the inline prompt.
    TriageCreate,
    /// The Triage mode's queue control was clicked; move the focus there. The
    /// queue has no action of its own, so this focuses and stops.
    TriageFocusQueue,
    /// A visible option row of the open Triage picker was clicked; choose it by
    /// its position in the filtered option list.
    TriagePickOption(usize),
    /// A click landed outside the open Triage picker; close it.
    TriageDropdownClose,
    /// A Cycle-mode row was clicked; select that cycle (tui-cycle-mode).
    CycleSelect(usize),
    /// A row of the shared flat issue table was clicked; open that issue's
    /// detail pane. The filename is store-relative, exactly what
    /// [`crate::storage::IssueSelector::Filename`] takes. Raised by both the
    /// Cycle mode's and the Milestones mode's issue table
    /// ([`crate::ui::issue_table`]).
    OpenIssue(String),
    /// The New Issue mode's Template row was clicked; open the template picker
    /// (tui-new-issue-mode).
    TemplateOpen,
    /// A visible option row of the open template picker was clicked; choose that
    /// template by its position in the filtered option list.
    TemplatePickOption(usize),
    /// A Config-mode section-rail entry was clicked; switch the section
    /// (tui-config-mode).
    ConfigSection(ConfigSection),
    /// A Config-mode cycle/milestone row was clicked; select that row's index
    /// in the planning cursor (tui-config-mode).
    ConfigPlanningRow(usize),
    /// A Config-mode Types/custom-attributes row was clicked; select that row's
    /// index in the section's list cursor (tui-config-mode).
    ConfigListRow(usize),
    /// A rail **branch** under the Board trunk was clicked; switch the active
    /// board to the named configuration (tui-mode-navigation).
    SelectBoard(String),
    /// A rail branch under the Cycles trunk was clicked; enter the Cycle mode
    /// focused on the named cycle (tui-mode-navigation).
    SelectCycle(String),
    /// A rail branch under the Milestones trunk was clicked; enter the
    /// Milestones mode focused on the named milestone (tui-mode-navigation).
    SelectMilestone(String),
    /// A rail branch under the New Issue trunk was clicked; open the New Issue
    /// form seeded from the named `[templates.<name>]` record.
    NewFromTemplate(String),
}
/// The capacity read the TUI renders (WP06, mission `cycle-capacity-planning-01M3C85C`).
///
/// One read of the shared pair ([`crate::commands::capacity::snapshot_for`] — the
/// schedule index and the one-pass issue snapshot) plus the display data derived from
/// it, built once per refresh. Holding the pair is what lets the Capacity tab, the
/// board's cycle group headers and an issue's detail all read **one** reading rather
/// than each building another, and it is what keeps a render from touching the disk at
/// all (NFR-001/C-001): the page draws only what a refresh already derived.
///
/// Nothing here writes: the pair is the read-only value the CLI's
/// `tix capacity calculate` reads, and the view is display data over the DTOs.
pub(in crate::app) struct CapacityCache {
    /// The schedule index the snapshot was built against.
    pub(in crate::app) index: crate::cycle_schedule::identity::ScheduleIndex,
    /// The one-pass issue snapshot every calculation reads.
    pub(in crate::app) snapshot: crate::capacity::scope::IssueSnapshot,
    /// The configured scopes' rows, details and board cells.
    pub(in crate::app) view: crate::ui::schedule::CapacityView,
    /// Whether every configured scope's calculation **was counted and came to zero**,
    /// so the page can say so rather than leave a reader to infer it from `-`.
    ///
    /// False as soon as any scope has no answer at all (an entry that is not in force)
    /// or has committed work: "not calculated" is not the claim "committed nothing".
    pub(in crate::app) nothing_committed: bool,
}
/// What a surface answered about one due candidate (WP07).
///
/// The four actions the warning overlay and the Schedule page share. It exists so
/// both doors call **one** implementation ([`App::apply_candidate`]): the overlay
/// maps its `WarningAction` onto these, the page's keys name them directly, and a
/// difference between the two surfaces is impossible to write by accident.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(in crate::app) enum CandidateAction {
    /// Create the occurrence (WP04, idempotent).
    Generate,
    /// Move the candidate past the date, without recording it as skipped.
    Defer,
    /// Record the date as skipped.
    Skip,
    /// Stop offering the date: recorded, like `skip`.
    Ignore,
}
/// Where a move should land: a visible column's cursor index, or a configured
/// column by name (including one hidden with the board's `c` toggle).
///
/// The board's `<`/`>` keys, drag-and-drop and the old context menu all moved by
/// visible index. The action dialog offers **every configured column**, which
/// includes hidden ones, so the prompt chain and the write path carry this type
/// rather than a bare `usize`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MoveTarget {
    Visible(usize),
    Named(String),
}
impl MoveTarget {
    /// The column's configured name, resolved through the board for a visible
    /// index and used verbatim for a named target.
    pub fn name(&self, board: &BoardState) -> String {
        match self {
            Self::Visible(index) => board.column_name(*index).to_string(),
            Self::Named(name) => name.clone(),
        }
    }

    pub fn visible_index(&self) -> Option<usize> {
        match self {
            Self::Visible(index) => Some(*index),
            Self::Named(_) => None,
        }
    }
}
/// A move that is waiting on one of its prompts.
///
/// The four prompts (blocked acknowledgement, terminal-exit reason, WIP override,
/// commit) used to re-enter the shared move carrying only *their own* answer, so
/// each prompt threw the others away. A blocked move out of a terminal column could
/// ask the same question twice, and a move into `done` dropped the reason at the
/// commit prompt. This struct is the one place those answers live between prompts.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingMove {
    pub filename: String,
    pub number: u32,
    pub target: MoveTarget,
    pub target_name: String,
    pub reason: Option<String>,
    pub acknowledged: bool,
    /// `None` means the commit prompt has not been answered; `Some(None)` means
    /// the user answered it with no hash, `Some(Some(hash))` with one.
    #[allow(clippy::option_option)]
    pub commit: Option<Option<String>>,
}
/// Which half of a two-step relationship prompt is being answered.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LinkStep {
    /// The other issue, as a selector.
    Target,
    /// The relationship kind. **Required** (C-001).
    Kind,
}
/// A rectangular click target recorded by a widget during drawing (D3), so the
/// same frame's pointer position can be resolved to an [`Action`] without the
/// widget knowing anything about mouse handling.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HitRegion {
    pub rect: Rect,
    pub action: Action,
}
/// Origin of a mouse press (D4): which column and (when the press landed on a
/// card) which card it started on, plus a timestamp used for the double-click
/// window. Kept by [`MouseState::last_down`] until a second press on the same
/// card, a drag, or the next distinct target replaces it.
#[derive(Debug)]
pub(in crate::app) struct MouseDown {
    pub(in crate::app) col: usize,
    pub(in crate::app) card: Option<usize>,
    pub(in crate::app) at: Instant,
}
/// An in-flight drag (D4): the pressed card's origin, so an `Up` can compare
/// the drop target against it and no-op for same-column / non-card drops.
#[derive(Debug)]
pub(in crate::app) struct Drag {
    pub(in crate::app) col: usize,
    pub(in crate::app) card: Option<usize>,
}
/// Mouse interaction state driven by `handle_board_mouse` (D4). A `Down`
/// records the press origin, `Drag` frames promote it into an active drag (no
/// board mutation while dragging), and the resolving `Up` decides between a
/// plain click / double-click / drag-drop.
#[derive(Debug)]
pub struct MouseState {
    pub(in crate::app) last_down: Option<MouseDown>,
    pub(in crate::app) drag: Option<Drag>,
    pub double_click_window: Duration,
}
impl Default for MouseState {
    fn default() -> Self {
        MouseState {
            last_down: None,
            drag: None,
            double_click_window: Duration::from_millis(500),
        }
    }
}
/// Rows a mouse-wheel notch scrolls, in every surface that scrolls.
///
/// One constant rather than a number per handler: the detail pane and the keybindings
/// overlay (and anything else that scrolls later) move by the same amount, or the
/// wheel means something different depending on where the pointer happens to be.
pub(in crate::app) const WHEEL_ROWS: u16 = 5;
/// How many detail panes the back stack keeps (tix #1513).
///
/// Following related links is unbounded, so the stack is not; the oldest pane is
/// dropped once the cap is reached, which keeps `Backspace` walking back toward
/// what the reader saw most recently.
pub(in crate::app) const MAX_DETAIL_NAV: usize = 32;
/// How much of the keybindings overlay fits, and how far it can scroll (tix #1441).
///
/// Recorded by the overlay's own draw: its *handlers* need what only the renderer
/// knows, which is why the two numbers travel back through a `Cell` rather than being
/// recomputed from the key that was pressed.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(in crate::app) struct HelpMetrics {
    /// Binding rows the overlay can show at once.
    pub(in crate::app) visible: u16,
    /// The largest `help_scroll` that still shows the last row.
    pub(in crate::app) max_scroll: u16,
}
/// The editor round trip in flight (tix #1510).
///
/// Recorded when the editor is launched and consumed when it exits. `snapshot` is
/// the issue file's raw bytes at launch, and re-reading the same path after the
/// child exits is what makes a concurrent write — an agent editing through MCP,
/// a watcher, another session — detectable at all.
pub(in crate::app) struct PendingEdit {
    /// The issue file, relative to the store's `issues_dir`.
    pub(in crate::app) filename: String,
    /// The temp copy the editor writes to.
    pub(in crate::app) temp: PathBuf,
    /// Raw bytes of the issue file when the editor was launched.
    pub(in crate::app) snapshot: Vec<u8>,
}
/// The saved view the live board is associated with (WP05, T024/C-006).
///
/// **Session-only.** The marker is never persisted (data-model §4's "if
/// explicitly persisted"), and applying a view writes nothing: it installs the
/// plan into the live filter state and records here that it did.
///
/// `visibility` records whether the applied plan's **visibility snapshot** is
/// still overlaid on the live board. It is cleared when the user changes column
/// visibility by hand, because that is a live change — and a live change is
/// exactly what the divergence report exists to name (C-006). The marker itself
/// is *not* cleared then: the view is still the one the board was associated
/// with, and "you are on `Foo` but its `sort` no longer matches" is the fact the
/// user needs.
pub(in crate::app) struct ViewSession {
    /// The stable id and the normalized plan that was applied.
    pub(in crate::app) marker: ActiveViewMarker,
    /// The display label, resolved at apply time. The name is not the identity
    /// (C-002), but the indicator has to read as something.
    pub(in crate::app) name: String,
    /// Whether the applied plan's visibility snapshot is still overlaid.
    pub(in crate::app) visibility: bool,
}