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::*;

impl App {
    /// The Library page's state (tix #1593), or a fresh default when the page is
    /// not open. The renderer and the key handler both read it, and `mode` already
    /// says whether the page is live, so a missing state is never an error.
    pub fn library_state(&self) -> crate::ui::library::LibraryState {
        self.library.clone().unwrap_or_default()
    }

    /// The Portfolio page's state (WP06), or a fresh default when the page is not
    /// open. The renderer and the key handler both read it, and `mode` already says
    /// whether the page is live, so a missing state is never an error.
    pub fn portfolio_state(&self) -> crate::ui::portfolio::PortfolioState {
        self.portfolio.clone().unwrap_or_default()
    }

    /// The service's project list as the **CLI** reads it (WP06).
    ///
    /// Built from `store.list_all()` — archived issues **included** — because that
    /// is the set `tix project list` and `tix project show` count over: the page's
    /// figures and the CLI's are the same figures, and neither moves when the board's
    /// archive toggle does. The result is cached ([`Self::refresh_portfolio_projects`])
    /// and dropped when the page is left, so drawing the page per frame does not
    /// re-read the issue tree (NFR-001).
    ///
    /// Nothing is derived here: `member_count` comes from
    /// [`crate::projects::list_projects`], which owns the membership rule (C-006).
    pub fn projects_snapshot(&self) -> Vec<crate::projects::Project> {
        self.portfolio_projects.clone().unwrap_or_default()
    }

    /// The Portfolio page's typed project health, as the **CLI** reads it (WP09).
    ///
    /// Cached beside [`Self::projects_snapshot`] from the same snapshot, so the
    /// page's progress and health figures are the values `tix project show` prints
    /// and no surface derives a second one (C-006). Empty when the page is not open
    /// or the read failed; the detail pane then renders the record alone.
    pub fn portfolio_reports(
        &self,
    ) -> std::collections::BTreeMap<crate::config::ProjectId, crate::projects::ProjectHealthReport>
    {
        self.portfolio_reports.clone().unwrap_or_default()
    }

    /// The Portfolio page's **Epics** tab rows, as the **CLI** reads them (WP06
    /// T032).
    ///
    /// The same [`crate::commands::view::epic_view`] value the epic view renders, so
    /// the tab and the CLI cannot disagree about a figure (C-008). Empty when the
    /// page is not open or the read failed; the tab then draws its explicit empty
    /// state rather than a fabricated row.
    pub fn portfolio_epics(&self) -> &[crate::commands::view::EpicViewEntry] {
        self.portfolio_epics.as_deref().unwrap_or_default()
    }

    /// The Portfolio page's **Roadmap** tab projection (WP05), or `None` while the
    /// page has not built one.
    ///
    /// The **same** [`crate::roadmap::projection::project`] value `tix roadmap list`
    /// renders, so the tab's rows, summaries and diagnostics are the CLI's (C-004,
    /// C-007).
    pub fn portfolio_roadmap(&self) -> Option<&crate::roadmap::RoadmapProjection> {
        self.portfolio_roadmap.as_ref()
    }

    /// The Roadmap tab's normalized filter (WP05).
    pub fn roadmap_filter(&self) -> &crate::roadmap::RoadmapFilter {
        &self.roadmap_filter
    }

    /// The Roadmap tab's open overlay (WP05), if any.
    pub fn roadmap_overlay(&self) -> Option<&crate::ui::roadmap::RoadmapOverlay> {
        self.portfolio_roadmap_overlay.as_ref()
    }

    /// Whether the initiative registry is empty (for the tab's explicit empty
    /// state).
    pub fn roadmap_registry_is_empty(&self) -> bool {
        self.store.config.initiatives.is_empty()
    }

    /// Re-read the Portfolio page's project list (WP06).
    ///
    /// Called on mode entry and on every page key, so a project deleted from another
    /// terminal is reflected the next time the user presses a key, while a frame
    /// draw stays a pure read of the cache.
    pub(in crate::app) fn refresh_portfolio_projects(&mut self) {
        let issues = self.store.list_all().unwrap_or_default();
        self.portfolio_projects = Some(crate::projects::list_projects(&self.store.config, &issues));
        // The typed figures come from the **one** service read path the CLI uses
        // (`project_reports` -> `ProjectIndex::health`), over the same `list_all`
        // snapshot, so the page's figure equals `tix project show`'s for the same
        // snapshot (WP09 item C, C-006). A read failure leaves the cache empty and
        // the pane renders the record alone rather than a fabricated zero.
        self.portfolio_reports = crate::commands::view::project_reports(&self.store, &issues)
            .ok()
            .map(|(_, reports)| reports);
        // The Epics tab, from the **one** service read path the CLI's `tix epic
        // list` / `tix epic show` use, over the **same** `list_all` snapshot as the
        // project list above — archived issues included — so the tab's member counts
        // and member lists equal the CLI's for the same snapshot (C-008). A read
        // failure leaves the cache empty and the tab renders its explicit empty
        // state.
        let done_column = self
            .store
            .graph_source()
            .map(|(_, column)| column)
            .unwrap_or_default();
        self.portfolio_epics =
            crate::commands::view::epic_view_over(&self.store.config, &issues, &done_column).ok();
        // The Roadmap tab joins the **same** `list_all` snapshot through the shared
        // projection, so its rows equal `tix roadmap list`'s for the same snapshot
        // (C-007). Only the active tab pays the join: a frame draw stays a pure read
        // of the cache, and the other tabs never build a projection they do not show.
        if self.portfolio_tab() == Some(crate::ui::portfolio::PortfolioTab::Roadmap) {
            self.refresh_portfolio_roadmap(&issues);
        }
    }

    /// Rebuild the Roadmap tab's projection from an already-read snapshot.
    ///
    /// A refused filter (or a read failure) leaves the cache empty; the tab then
    /// renders its explicit empty state rather than a stale or fabricated one. The
    /// filter is validated again by the projection, so an invalid one can never be
    /// installed in the first place.
    pub(in crate::app) fn refresh_portfolio_roadmap(&mut self, issues: &[crate::model::Issue]) {
        self.portfolio_roadmap = crate::ui::roadmap::projection_over(
            &self.store.config,
            &self.store,
            issues,
            &self.roadmap_filter,
        )
        .ok();
    }

    /// The Portfolio page's active tab, or `None` when the page is not open.
    pub(in crate::app) fn portfolio_tab(&self) -> Option<crate::ui::portfolio::PortfolioTab> {
        self.portfolio.as_ref().map(|state| state.tab)
    }

    /// The service's project list, read live (WP06).
    ///
    /// For the **pickers** (the issue form's Projects row and the filter modal's
    /// Project row), which are opened once per interaction rather than drawn per
    /// frame: the list has to reflect what `tix project create` wrote a moment ago in
    /// another terminal, and it is read from `store.list_all()` — archived issues
    /// included — so it is the same set `tix project list` shows. The page's own rows
    /// come from the cache above, for the reason that cache documents.
    pub(in crate::app) fn live_projects(&self) -> Vec<crate::projects::Project> {
        let issues = self.store.list_all().unwrap_or_default();
        crate::projects::list_projects(&self.store.config, &issues)
    }

    /// The service's epic list, read live (WP06).
    ///
    /// The epic twin of [`Self::live_projects`], for the pickers (the issue form's
    /// Epics row and the filter modal's Epic row), which are opened once per
    /// interaction rather than drawn per frame. The list is read from
    /// `store.list_all()` — archived issues included — so a member's count matches
    /// `tix epic list`'s.
    pub(in crate::app) fn live_epics(&self) -> Vec<crate::epics::Epic> {
        let issues = self.store.list_all().unwrap_or_default();
        crate::epics::list_epics(&self.store.config, &issues)
    }

    /// The F10 Portfolio page's keys (WP06).
    ///
    /// `j`/`k` and the arrows walk the active tab's rows, `Tab`/`Shift+Tab` switch
    /// between the Projects and Epics registries (resetting the cursor, which indexes
    /// the active tab's rows), `h`/`l` walk the selected epic's members on the Epics
    /// tab, `Enter` opens the selected member issue's detail **directly** (FR-004),
    /// `q`/`esc` return to the board, and `?` opens the help overlay. There is no
    /// mutating key: the page is a reader, and every write lives on
    /// `tix project …`/`tix epic …` and the issue form.
    ///
    /// The cursor is clamped to the active registry on every key, because the
    /// registry can change under the page: `tix project delete` in another terminal,
    /// or a reload that drops a record this session did not touch.
    pub(in crate::app) fn handle_portfolio_key(&mut self, key: KeyEvent) -> Result<Action> {
        if !matches!(self.mode, Mode::Portfolio) {
            return Ok(Action::None);
        }
        // Re-read first: the registry can change under the page (a project deleted
        // from the CLI), and the cursor must clamp against the set this key sees.
        self.refresh_portfolio_projects();
        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 let Some(state) = self.portfolio.as_mut() {
            state.clamp_selected(rows);
        }
        // The selected epic's member count, for the member cursor's clamp and the
        // `Enter` target. Empty on the Projects and Roadmap tabs.
        let member_len = match tab {
            crate::ui::portfolio::PortfolioTab::Projects
            | crate::ui::portfolio::PortfolioTab::Roadmap => 0,
            crate::ui::portfolio::PortfolioTab::Epics => {
                let selected = self.portfolio_state().selected;
                self.portfolio_epics()
                    .get(selected)
                    .map_or(0, |entry| entry.members.len())
            }
        };
        if let Some(state) = self.portfolio.as_mut() {
            state.clamp_member(member_len);
        }
        match key.code {
            KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => {
                // The field's contract is "`Some` exactly while `mode` is
                // `Mode::Portfolio`", so the page's own close clears it rather than
                // leaving it for the next mode switch.
                self.portfolio = None;
                self.mode = Mode::Board;
            }
            KeyCode::Tab => {
                if let Some(state) = self.portfolio.as_mut() {
                    state.switch_tab(1);
                }
                // The overlay belongs to the tab it was opened on, and the newly
                // active tab's cache must be built before the next frame draws it.
                self.portfolio_roadmap_overlay = None;
                self.refresh_portfolio_projects();
            }
            KeyCode::BackTab => {
                if let Some(state) = self.portfolio.as_mut() {
                    state.switch_tab(-1);
                }
                self.portfolio_roadmap_overlay = None;
                self.refresh_portfolio_projects();
            }
            KeyCode::Up | KeyCode::Char('k') => {
                if let Some(state) = self.portfolio.as_mut() {
                    state.move_selected(-1, rows);
                }
            }
            KeyCode::Down | KeyCode::Char('j') => {
                if let Some(state) = self.portfolio.as_mut() {
                    state.move_selected(1, rows);
                }
            }
            KeyCode::Left | KeyCode::Char('h') => {
                if let Some(state) = self.portfolio.as_mut() {
                    state.move_member(-1, member_len);
                }
            }
            KeyCode::Right | KeyCode::Char('l') => {
                if let Some(state) = self.portfolio.as_mut() {
                    state.move_member(1, member_len);
                }
            }
            KeyCode::Enter => {
                // The Epics tab's direct issue navigation (FR-004): `Enter` opens the
                // selected member's own detail, never an epic pane. A no-op on the
                // Projects tab (a reader) or when the selected epic has no members.
                if tab == crate::ui::portfolio::PortfolioTab::Epics {
                    let state = self.portfolio_state();
                    let target = self
                        .portfolio_epics()
                        .get(state.selected)
                        .and_then(|entry| entry.members.get(state.member))
                        .map(|member| member.number);
                    if let Some(number) = target {
                        // Leave the page before opening the pane, so the page state's
                        // "`Some` exactly while the mode is Portfolio" contract holds.
                        self.portfolio = None;
                        let issue = self.store.resolve_issue(&number.to_string())?;
                        self.open_detail_root(issue)?;
                    }
                }
            }
            KeyCode::Char('?') => self.open_help(Mode::Portfolio),
            _ => {}
        }
        Ok(Action::None)
    }
}