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 {
    /// Build the archived-inclusive search corpus on first use (tix #1498).
    ///
    /// A build error leaves `search_corpus` as `None`; the callers fall back to
    /// the active corpus, so a transient read failure degrades the search to
    /// "archived issues are excluded" instead of "no search at all".
    pub(in crate::app) fn ensure_search_corpus(&mut self) {
        if self.search_corpus.is_none() {
            if let Ok(c) = Corpus::build_with(&self.store, true) {
                self.search_corpus = Some(c);
            }
        }
    }

    /// Re-read the store and rebuild the board while preserving the current
    /// filter, active column, and selection.
    /// Re-read the repository's health document.
    ///
    /// Called wherever the board is rebuilt, so every pane shows the state the files
    /// are in. A failure keeps the previous document rather than blanking every health
    /// field: a repository this build cannot read is a fact the diagnostics report, not
    /// a reason to forget what it said a moment ago.
    pub(in crate::app) fn refresh_health(&mut self) {
        if let Ok(document) = crate::commands::export::run_health_core(&self.store.config) {
            self.health = Some(document);
        }
    }

    pub(in crate::app) fn rebuild_board(&mut self) {
        self.refresh_health();
        let active_col = self.board.active_col;
        let selected = self.board.selected;
        let board_config = self.board_config();
        let active_filter = self.active_structured_filter().clone();
        let q = self.filter.clone().unwrap_or_default();
        if !q.is_empty() || active_filter.has_active_criteria() {
            if !q.is_empty() {
                self.ensure_search_corpus();
            }
            let corpus = if q.is_empty() {
                &self.corpus
            } else {
                self.search_corpus.as_ref().unwrap_or(&self.corpus)
            };
            self.board = BoardState::build_filtered_with(
                &board_config,
                &self.store.config,
                corpus,
                &q,
                &active_filter,
                &self.workflow_resolution(),
                self.show_children,
            );
        } else {
            // The corpus is the single in-memory source (issue #10 follow-up):
            // every write through the store refreshes it, so the unfiltered
            // board can be rebuilt from memory instead of re-reading the tree.
            let resolution = self.workflow_resolution();
            self.board = BoardState::build_from_issues(
                &self.corpus.issues,
                &board_config,
                &self.store.config,
                &resolution,
                self.show_children,
            );
        }
        self.board.active_col = active_col;
        self.board.selected = selected;
        self.board.clamp_selection();
        // Every live change funnels through here, so this is where the active
        // view's indicator is re-derived from the state that just changed (WP05,
        // T026). Doing it only at apply time would let the indicator go stale the
        // first time the user edited the filter.
        self.sync_filter_modal_feedback();
    }

    pub(in crate::app) fn select_card_by_filename(&mut self, filename: &str) {
        let mut columns: Vec<&[crate::ui::board::Card]> = self
            .board
            .columns
            .iter()
            .map(|c| c.cards.as_slice())
            .collect();
        if self.board.show_mismatch {
            columns.push(self.board.mismatch.cards.as_slice());
        }
        if let Some((col_idx, card_idx)) =
            columns.iter().enumerate().find_map(|(col_idx, cards)| {
                cards
                    .iter()
                    .position(|card| card.filename == filename)
                    .map(|card_idx| (col_idx, card_idx))
            })
        {
            self.board.active_col = col_idx;
            self.board.selected = card_idx;
        }
    }

    /// Live filter: update the applied `filter` and re-render the board from
    /// the corpus. An empty buffer clears the query (FR-013: an active
    /// structured filter alone still filters).
    pub(in crate::app) fn apply_filter(&mut self, buffer: &str) {
        if buffer.trim().is_empty() {
            self.filter = None;
        } else {
            self.filter = Some(buffer.trim().to_string());
        }
        let board_config = self.board_config();
        let active_filter = self.active_structured_filter().clone();
        let q = self.filter.clone().unwrap_or_default();
        if !q.is_empty() || active_filter.has_active_criteria() {
            if !q.is_empty() {
                self.ensure_search_corpus();
            }
            let corpus = if q.is_empty() {
                &self.corpus
            } else {
                self.search_corpus.as_ref().unwrap_or(&self.corpus)
            };
            self.board = BoardState::build_filtered_with(
                &board_config,
                &self.store.config,
                corpus,
                &q,
                &active_filter,
                &self.workflow_resolution(),
                self.show_children,
            );
            self.status_msg = if self.board.is_empty() {
                Some("No matches for current search".to_string())
            } else {
                None
            };
        } else {
            self.board =
                BoardState::build_with(&self.store, &board_config, false, self.show_children);
            self.status_msg = None;
        }
        // The active view's indicator is derived from the state that just changed
        // (WP05, T026) — the `/` prompt and the filter modal both end up here.
        self.sync_filter_modal_feedback();
    }

    /// Re-read the corpus after comment/asset writes so the active filter
    /// stays fresh, then rebuilds the board.
    /// Re-read the open detail pane from the service after a write.
    ///
    /// **Everything** the pane shows is re-derived, not just the issue: the
    /// relationships come from `Store::relationships_for` and the chain and blocked
    /// note from a fresh graph. A pane that refreshed only `issue` would keep showing
    /// the edge list and blocked state from *before* the mutation, which is the
    /// failure that makes a TUI feel like it is lying to you.
    ///
    /// The relationship cursor is clamped rather than reset, so editing the third of
    /// five relationships leaves it on the third — which after a re-kind is the same
    /// edge, since the list is ordered by `(kind, target, from)` and a re-kind can
    /// move it. Clamping is the honest minimum: it never points past the end.
    pub(in crate::app) fn refresh_detail(&mut self) -> Result<()> {
        let Some(detail) = self.detail.as_ref() else {
            return Ok(());
        };
        let number = detail.issue.number;
        let filename = detail.issue.filename.clone();
        let Some(mut next) = self.store.get(IssueSelector::Filename(filename)).ok() else {
            return Ok(());
        };
        let relationships = self.store.relationships_for(number)?;
        let (all, done_column) = self.store.graph_source()?;
        let graph = commands::graph_over(&all, &done_column);
        let cursor = detail.rel_cursor;
        let related_cursor = detail.related_cursor;
        if let Some(d) = self.detail.as_mut() {
            d.issue = std::mem::replace(&mut next, d.issue.clone());
            d.relationships = relationships;
            d.chain = graph.dependency_chain(number);
            d.blocked = commands::blocked_note(&graph.blocked_state(number));
            d.rel_cursor = cursor;
            d.clamp_rel_cursor();
            // #1513: the `related_issues` field may have been edited, so the
            // classified list is re-derived with everything else. The cursor is
            // clamped rather than reset, the same rule the relationship cursor
            // follows.
            d.related = crate::related::classify_all(&d.issue.related_issues);
            d.related_cursor = related_cursor;
            d.clamp_related_cursor();
        }
        self.refresh_corpus()
    }

    pub(in crate::app) fn refresh_corpus(&mut self) -> Result<()> {
        match crate::storage::take_internal_issue_writes() {
            crate::storage::InternalIssueWrites::Delta(delta) => {
                let delta = self.delta_for_view(&delta);
                self.corpus.apply(&self.store, &delta)?;
            }
            crate::storage::InternalIssueWrites::None
            | crate::storage::InternalIssueWrites::Overflow => {
                self.corpus = Corpus::build_with(&self.store, self.show_archived)?;
            }
        }
        // Start/refresh detection (WP07 T033): read-only, and it never replaces an
        // overlay that is already open (T034) — a warning cannot vanish under the
        // cursor that is answering it.
        self.refresh_recurring_warnings(false);
        // The mention count, on the same read-only pass every refresh runs.
        self.refresh_mentions();
        // The cycle-schedule pass (WP07 T035). It is the same contract — read-only,
        // derived from what is on disk, no cursor advanced — and it is run here,
        // once per refresh, so the Cycle schedules tab and every other surface read
        // the same evaluation (C-008).
        self.refresh_cycle_schedule_warnings();
        // The capacity read (WP06 T030–T033): the same one-pass shape, read here so
        // the Capacity tab, the board's cycle groups and every detail pane agree, and
        // so a render never touches the disk.
        self.refresh_capacity_view();
        // The archived-inclusive search snapshot is stale now; the next search
        // rebuilds it from disk (tix #1498).
        self.search_corpus = None;
        // WP05 T025: the snapshot the headers, the detail pane and the next policy
        // decision all read. Built here, once per refresh, from the corpus the board
        // is built from — the same slice, so the two cannot disagree.
        self.wip =
            crate::wip::WipSnapshot::build(self.store.config.default_config(), &self.corpus.issues);
        self.rebuild_board();
        Ok(())
    }

    /// Apply an external change to the in-memory corpus and rebuild the board,
    /// **without re-reading the issues tree** (issue #10 follow-up).
    ///
    /// `delta` comes from [`crate::watcher::FsWatcher::poll`] with paths
    /// relative to the issues directory; [`Corpus::apply`] re-reads only those
    /// files and drops the removed ones. A read error (e.g. a partially written
    /// file) leaves the current corpus and board in place.
    ///
    /// The delta is narrowed to the current view first: with the archived
    /// toggle off, an archived path is dropped rather than read, so an issue
    /// archived outside `tix` leaves the active board instead of appearing on
    /// it (the archived-inclusive [`App::ensure_search_corpus`] snapshot is
    /// dropped and rebuilt from disk on the next search, as after any refresh).
    pub fn refresh_from_disk(&mut self, delta: &FsDelta) -> Result<()> {
        let delta = self.delta_for_view(delta);
        self.corpus.apply(&self.store, &delta)?;
        self.search_corpus = None;
        self.rebuild_board();
        Ok(())
    }

    /// Narrow `delta` to the paths the current view indexes (issue #10
    /// follow-up).
    ///
    /// With the archived toggle off, a dot-prefixed file is not part of the
    /// corpus, so a change to one is not a change to this view. With it on,
    /// every path passes through and [`Corpus::apply`] judges `.md`/segment
    /// rules.
    pub(in crate::app) fn delta_for_view(&self, delta: &FsDelta) -> FsDelta {
        if self.show_archived {
            return delta.clone();
        }
        let visible = |path: &PathBuf| {
            !path
                .file_name()
                .and_then(|name| name.to_str())
                .is_some_and(|name| name.starts_with('.'))
        };
        FsDelta {
            changed: delta
                .changed
                .iter()
                .filter(|p| visible(p))
                .cloned()
                .collect(),
            removed: delta
                .removed
                .iter()
                .filter(|p| visible(p))
                .cloned()
                .collect(),
        }
    }
}