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 Roadmap tab's own command letters, read **raw** (WP05).
    ///
    /// `c` creates, `e` renames, `a` associates, `x` dereferences, `f` opens the
    /// filter editor and `r` re-reads. Returns whether the key was one of them, so
    /// every other key still resolves through the page's bindings. A no-op on the
    /// page's other tabs.
    pub(in crate::app) fn handle_roadmap_tab_key(&mut self, key: KeyEvent) -> Result<bool> {
        use crate::ui::roadmap::{
            AssocVerb, AssociationDraft, FilterDraft, NameDraft, RoadmapOverlay,
        };
        if self.portfolio_tab() != Some(crate::ui::portfolio::PortfolioTab::Roadmap) {
            return Ok(false);
        }
        let overlay = match key.code {
            KeyCode::Char('c') => RoadmapOverlay::Create(NameDraft::default()),
            KeyCode::Char('e') => {
                let Some(name) = self.selected_roadmap_name() else {
                    self.action_feedback = Some(super::ActionFeedback::new(
                        "rename initiative",
                        "no initiative is selected",
                        "press c to create one, or move the cursor onto an initiative",
                    ));
                    return Ok(true);
                };
                RoadmapOverlay::Rename(NameDraft { value: name })
            }
            KeyCode::Char('a') => {
                RoadmapOverlay::Associate(AssociationDraft::new(AssocVerb::Associate))
            }
            KeyCode::Char('x') => {
                RoadmapOverlay::Associate(AssociationDraft::new(AssocVerb::Dereference))
            }
            KeyCode::Char('f') => {
                RoadmapOverlay::Filter(FilterDraft::from_filter(&self.roadmap_filter))
            }
            KeyCode::Char('r') => {
                self.refresh_portfolio_projects();
                self.clamp_roadmap_cursor();
                if self
                    .portfolio_roadmap()
                    .is_none_or(|projection| projection.initiatives.is_empty())
                {
                    self.action_feedback = Some(super::ActionFeedback::new(
                        "refresh roadmap",
                        "the registry re-read produced no rows",
                        "press c to create an initiative, then refresh again",
                    ));
                } else {
                    self.status_msg = Some("Roadmap refreshed".to_string());
                }
                return Ok(true);
            }
            _ => return Ok(false),
        };
        self.portfolio_roadmap_overlay = Some(overlay);
        Ok(true)
    }

    /// The Roadmap tab's overlay keys, read raw: the overlay is a text surface.
    ///
    /// `enter` applies through the registry (or installs a validated filter), `esc`
    /// cancels, `tab`/`↓↑` move the focused field, `←`/`→` cycle an association's
    /// kind, and every character edits the focused field — including `j`, `q` and
    /// `?`, which the page's bindings would otherwise claim.
    pub(in crate::app) fn handle_roadmap_overlay_key(&mut self, key: KeyEvent) -> Result<Action> {
        let Some(overlay) = self.portfolio_roadmap_overlay.clone() else {
            return Ok(Action::None);
        };
        match key.code {
            KeyCode::Esc => self.portfolio_roadmap_overlay = None,
            KeyCode::Enter => return self.apply_roadmap_overlay(overlay),
            KeyCode::Tab | KeyCode::Down => {
                if let Some(open) = self.portfolio_roadmap_overlay.as_mut() {
                    open.focus(1);
                }
            }
            KeyCode::BackTab | KeyCode::Up => {
                if let Some(open) = self.portfolio_roadmap_overlay.as_mut() {
                    open.focus(-1);
                }
            }
            KeyCode::Left => {
                if let Some(open) = self.portfolio_roadmap_overlay.as_mut() {
                    open.cycle_kind(-1);
                }
            }
            KeyCode::Right => {
                if let Some(open) = self.portfolio_roadmap_overlay.as_mut() {
                    open.cycle_kind(1);
                }
            }
            KeyCode::Backspace => {
                if let Some(open) = self.portfolio_roadmap_overlay.as_mut() {
                    open.backspace();
                }
            }
            KeyCode::Char(ch) => {
                if let Some(open) = self.portfolio_roadmap_overlay.as_mut() {
                    open.push(ch);
                }
            }
            _ => {}
        }
        Ok(Action::None)
    }

    /// Apply an overlay through the **shared** registry service (WP05).
    ///
    /// Every write goes through [`crate::roadmap::registry`]'s atomic boundary with
    /// the live `tix.toml` path — the same writer `tix initiative …` uses — so a
    /// refusal leaves the file byte-identical and no second writer exists (C-008).
    /// A refusal keeps the overlay open (so its text is not lost) and reports the
    /// service's own message; an accepted change closes it and re-reads the page.
    pub(in crate::app) fn apply_roadmap_overlay(
        &mut self,
        overlay: crate::ui::roadmap::RoadmapOverlay,
    ) -> Result<Action> {
        use crate::ui::roadmap::{AssocVerb, RoadmapOverlay};
        match overlay {
            RoadmapOverlay::Filter(draft) => {
                let filter = draft.to_filter();
                if let Err(err) = filter.validate() {
                    self.status_msg = Some(err.to_string());
                    return Ok(Action::None);
                }
                self.roadmap_filter = filter;
                self.portfolio_roadmap_overlay = None;
                self.refresh_portfolio_projects();
                // The new filter can select fewer rows than the cursor indexes; the
                // page's per-key clamp does not run on this path, so clamp here.
                self.clamp_roadmap_cursor();
            }
            RoadmapOverlay::Create(draft) => {
                let new = crate::ui::roadmap::new_initiative(&draft);
                if new.name.is_empty() {
                    self.status_msg = Some("an initiative needs a name".to_string());
                    return Ok(Action::None);
                }
                let Some(path) = self.roadmap_config_path() else {
                    return Ok(Action::None);
                };
                match crate::roadmap::registry::create_initiative(
                    &mut self.store.config,
                    &path,
                    &new,
                ) {
                    Ok(id) => {
                        self.status_msg = Some(format!("Created initiative {id}"));
                        self.portfolio_roadmap_overlay = None;
                        self.refresh_portfolio_projects();
                    }
                    Err(err) => self.status_msg = Some(err.to_string()),
                }
            }
            RoadmapOverlay::Rename(draft) => {
                let Some(id) = self.selected_roadmap_id() else {
                    self.status_msg = Some("no initiative selected to rename".to_string());
                    return Ok(Action::None);
                };
                if draft.value.trim().is_empty() {
                    self.status_msg = Some("an initiative needs a name".to_string());
                    return Ok(Action::None);
                }
                let Ok(initiative) = crate::config::InitiativeId::new(&id) else {
                    self.status_msg = Some(format!("unknown initiative `{id}`"));
                    return Ok(Action::None);
                };
                let Some(path) = self.roadmap_config_path() else {
                    return Ok(Action::None);
                };
                let edit = crate::ui::roadmap::rename_edit(&draft);
                match crate::roadmap::registry::edit_initiative(
                    &mut self.store.config,
                    &path,
                    &initiative,
                    &edit,
                ) {
                    // The id is the identity: a rename is a label change, and the
                    // stable id (and every association) survives (C-002).
                    Ok(id) => {
                        self.status_msg = Some(format!("Renamed initiative {id}"));
                        self.portfolio_roadmap_overlay = None;
                        self.refresh_portfolio_projects();
                    }
                    Err(err) => self.status_msg = Some(err.to_string()),
                }
            }
            RoadmapOverlay::Associate(draft) => {
                let Some(reference) = self.selected_roadmap_id() else {
                    self.status_msg = Some("no initiative selected".to_string());
                    return Ok(Action::None);
                };
                let target = draft.target.trim().to_string();
                if target.is_empty() {
                    self.status_msg = Some("a reference target is required".to_string());
                    return Ok(Action::None);
                }
                let Some(path) = self.roadmap_config_path() else {
                    return Ok(Action::None);
                };
                let result = match draft.verb {
                    AssocVerb::Associate => crate::roadmap::registry::associate(
                        &mut self.store.config,
                        &path,
                        &reference,
                        draft.kind,
                        &target,
                    ),
                    AssocVerb::Dereference => crate::roadmap::registry::dissociate(
                        &mut self.store.config,
                        &path,
                        &reference,
                        draft.kind,
                        &target,
                    ),
                };
                match result {
                    Ok(changed) => {
                        let verb = match (draft.verb, changed) {
                            (AssocVerb::Associate, true) => "Associated",
                            (AssocVerb::Associate, false) => "Already associated",
                            (AssocVerb::Dereference, true) => "Dereferenced",
                            (AssocVerb::Dereference, false) => "Not associated",
                        };
                        self.status_msg = Some(format!(
                            "{verb} {} {target} with initiative {reference}",
                            draft.kind.field()
                        ));
                        self.portfolio_roadmap_overlay = None;
                        if changed {
                            self.refresh_portfolio_projects();
                        }
                    }
                    Err(err) => self.status_msg = Some(err.to_string()),
                }
            }
        }
        Ok(Action::None)
    }

    /// The live `tix.toml` path a registry write targets, reporting a path failure
    /// the way every other refusal is reported.
    pub(in crate::app) fn roadmap_config_path(&mut self) -> Option<std::path::PathBuf> {
        match self.store.config_path() {
            Ok(path) => Some(path),
            Err(err) => {
                self.status_msg = Some(format!("initiative: {err}"));
                None
            }
        }
    }

    /// Clamp the Roadmap cursor into the current projection's rows.
    ///
    /// A refresh **inside** a handler — an applied filter, an `r` re-read — can
    /// shrink the list under the cursor, and the page's per-key clamp in
    /// [`Self::handle_portfolio_key`] does not run on those paths. Without this the
    /// cursor would index a row that no longer exists: the next frame would draw no
    /// marker and an immediate `e`/`a`/`x` would report "no initiative selected".
    pub(in crate::app) fn clamp_roadmap_cursor(&mut self) {
        let rows = self
            .portfolio_roadmap()
            .map_or(0, |projection| projection.initiatives.len());
        if let Some(state) = self.portfolio.as_mut() {
            state.clamp_selected(rows);
        }
    }

    /// The selected Roadmap row's stable id.
    pub(in crate::app) fn selected_roadmap_id(&self) -> Option<String> {
        let selected = self.portfolio_state().selected;
        self.portfolio_roadmap()
            .and_then(|projection| projection.initiatives.get(selected))
            .map(|row| row.id.clone())
    }

    /// The selected Roadmap row's display name.
    pub(in crate::app) fn selected_roadmap_name(&self) -> Option<String> {
        let selected = self.portfolio_state().selected;
        self.portfolio_roadmap()
            .and_then(|projection| projection.initiatives.get(selected))
            .map(|row| row.name.clone())
    }
}