Skip to main content

tix/app/
refresh.rs

1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4impl App {
5    /// Build the archived-inclusive search corpus on first use (tix #1498).
6    ///
7    /// A build error leaves `search_corpus` as `None`; the callers fall back to
8    /// the active corpus, so a transient read failure degrades the search to
9    /// "archived issues are excluded" instead of "no search at all".
10    pub(in crate::app) fn ensure_search_corpus(&mut self) {
11        if self.search_corpus.is_none() {
12            if let Ok(c) = Corpus::build_with(&self.store, true) {
13                self.search_corpus = Some(c);
14            }
15        }
16    }
17
18    /// Re-read the store and rebuild the board while preserving the current
19    /// filter, active column, and selection.
20    /// Re-read the repository's health document.
21    ///
22    /// Called wherever the board is rebuilt, so every pane shows the state the files
23    /// are in. A failure keeps the previous document rather than blanking every health
24    /// field: a repository this build cannot read is a fact the diagnostics report, not
25    /// a reason to forget what it said a moment ago.
26    pub(in crate::app) fn refresh_health(&mut self) {
27        if let Ok(document) = crate::commands::export::run_health_core(&self.store.config) {
28            self.health = Some(document);
29        }
30    }
31
32    pub(in crate::app) fn rebuild_board(&mut self) {
33        self.refresh_health();
34        let active_col = self.board.active_col;
35        let selected = self.board.selected;
36        let board_config = self.board_config();
37        let active_filter = self.active_structured_filter().clone();
38        let q = self.filter.clone().unwrap_or_default();
39        if !q.is_empty() || active_filter.has_active_criteria() {
40            if !q.is_empty() {
41                self.ensure_search_corpus();
42            }
43            let corpus = if q.is_empty() {
44                &self.corpus
45            } else {
46                self.search_corpus.as_ref().unwrap_or(&self.corpus)
47            };
48            self.board = BoardState::build_filtered_with(
49                &board_config,
50                &self.store.config,
51                corpus,
52                &q,
53                &active_filter,
54                &self.workflow_resolution(),
55                self.show_children,
56            );
57        } else {
58            // The corpus is the single in-memory source (issue #10 follow-up):
59            // every write through the store refreshes it, so the unfiltered
60            // board can be rebuilt from memory instead of re-reading the tree.
61            let resolution = self.workflow_resolution();
62            self.board = BoardState::build_from_issues(
63                &self.corpus.issues,
64                &board_config,
65                &self.store.config,
66                &resolution,
67                self.show_children,
68            );
69        }
70        self.board.active_col = active_col;
71        self.board.selected = selected;
72        self.board.clamp_selection();
73        // Every live change funnels through here, so this is where the active
74        // view's indicator is re-derived from the state that just changed (WP05,
75        // T026). Doing it only at apply time would let the indicator go stale the
76        // first time the user edited the filter.
77        self.sync_filter_modal_feedback();
78    }
79
80    pub(in crate::app) fn select_card_by_filename(&mut self, filename: &str) {
81        let mut columns: Vec<&[crate::ui::board::Card]> = self
82            .board
83            .columns
84            .iter()
85            .map(|c| c.cards.as_slice())
86            .collect();
87        if self.board.show_mismatch {
88            columns.push(self.board.mismatch.cards.as_slice());
89        }
90        if let Some((col_idx, card_idx)) =
91            columns.iter().enumerate().find_map(|(col_idx, cards)| {
92                cards
93                    .iter()
94                    .position(|card| card.filename == filename)
95                    .map(|card_idx| (col_idx, card_idx))
96            })
97        {
98            self.board.active_col = col_idx;
99            self.board.selected = card_idx;
100        }
101    }
102
103    /// Live filter: update the applied `filter` and re-render the board from
104    /// the corpus. An empty buffer clears the query (FR-013: an active
105    /// structured filter alone still filters).
106    pub(in crate::app) fn apply_filter(&mut self, buffer: &str) {
107        if buffer.trim().is_empty() {
108            self.filter = None;
109        } else {
110            self.filter = Some(buffer.trim().to_string());
111        }
112        let board_config = self.board_config();
113        let active_filter = self.active_structured_filter().clone();
114        let q = self.filter.clone().unwrap_or_default();
115        if !q.is_empty() || active_filter.has_active_criteria() {
116            if !q.is_empty() {
117                self.ensure_search_corpus();
118            }
119            let corpus = if q.is_empty() {
120                &self.corpus
121            } else {
122                self.search_corpus.as_ref().unwrap_or(&self.corpus)
123            };
124            self.board = BoardState::build_filtered_with(
125                &board_config,
126                &self.store.config,
127                corpus,
128                &q,
129                &active_filter,
130                &self.workflow_resolution(),
131                self.show_children,
132            );
133            self.status_msg = if self.board.is_empty() {
134                Some("No matches for current search".to_string())
135            } else {
136                None
137            };
138        } else {
139            self.board =
140                BoardState::build_with(&self.store, &board_config, false, self.show_children);
141            self.status_msg = None;
142        }
143        // The active view's indicator is derived from the state that just changed
144        // (WP05, T026) — the `/` prompt and the filter modal both end up here.
145        self.sync_filter_modal_feedback();
146    }
147
148    /// Re-read the corpus after comment/asset writes so the active filter
149    /// stays fresh, then rebuilds the board.
150    /// Re-read the open detail pane from the service after a write.
151    ///
152    /// **Everything** the pane shows is re-derived, not just the issue: the
153    /// relationships come from `Store::relationships_for` and the chain and blocked
154    /// note from a fresh graph. A pane that refreshed only `issue` would keep showing
155    /// the edge list and blocked state from *before* the mutation, which is the
156    /// failure that makes a TUI feel like it is lying to you.
157    ///
158    /// The relationship cursor is clamped rather than reset, so editing the third of
159    /// five relationships leaves it on the third — which after a re-kind is the same
160    /// edge, since the list is ordered by `(kind, target, from)` and a re-kind can
161    /// move it. Clamping is the honest minimum: it never points past the end.
162    pub(in crate::app) fn refresh_detail(&mut self) -> Result<()> {
163        let Some(detail) = self.detail.as_ref() else {
164            return Ok(());
165        };
166        let number = detail.issue.number;
167        let filename = detail.issue.filename.clone();
168        let Some(mut next) = self.store.get(IssueSelector::Filename(filename)).ok() else {
169            return Ok(());
170        };
171        let relationships = self.store.relationships_for(number)?;
172        let (all, done_column) = self.store.graph_source()?;
173        let graph = commands::graph_over(&all, &done_column);
174        let cursor = detail.rel_cursor;
175        let related_cursor = detail.related_cursor;
176        if let Some(d) = self.detail.as_mut() {
177            d.issue = std::mem::replace(&mut next, d.issue.clone());
178            d.relationships = relationships;
179            d.chain = graph.dependency_chain(number);
180            d.blocked = commands::blocked_note(&graph.blocked_state(number));
181            d.rel_cursor = cursor;
182            d.clamp_rel_cursor();
183            // #1513: the `related_issues` field may have been edited, so the
184            // classified list is re-derived with everything else. The cursor is
185            // clamped rather than reset, the same rule the relationship cursor
186            // follows.
187            d.related = crate::related::classify_all(&d.issue.related_issues);
188            d.related_cursor = related_cursor;
189            d.clamp_related_cursor();
190        }
191        self.refresh_corpus()
192    }
193
194    pub(in crate::app) fn refresh_corpus(&mut self) -> Result<()> {
195        match crate::storage::take_internal_issue_writes() {
196            crate::storage::InternalIssueWrites::Delta(delta) => {
197                let delta = self.delta_for_view(&delta);
198                self.corpus.apply(&self.store, &delta)?;
199            }
200            crate::storage::InternalIssueWrites::None
201            | crate::storage::InternalIssueWrites::Overflow => {
202                self.corpus = Corpus::build_with(&self.store, self.show_archived)?;
203            }
204        }
205        // Start/refresh detection (WP07 T033): read-only, and it never replaces an
206        // overlay that is already open (T034) — a warning cannot vanish under the
207        // cursor that is answering it.
208        self.refresh_recurring_warnings(false);
209        // The mention count, on the same read-only pass every refresh runs.
210        self.refresh_mentions();
211        // The cycle-schedule pass (WP07 T035). It is the same contract — read-only,
212        // derived from what is on disk, no cursor advanced — and it is run here,
213        // once per refresh, so the Cycle schedules tab and every other surface read
214        // the same evaluation (C-008).
215        self.refresh_cycle_schedule_warnings();
216        // The capacity read (WP06 T030–T033): the same one-pass shape, read here so
217        // the Capacity tab, the board's cycle groups and every detail pane agree, and
218        // so a render never touches the disk.
219        self.refresh_capacity_view();
220        // The archived-inclusive search snapshot is stale now; the next search
221        // rebuilds it from disk (tix #1498).
222        self.search_corpus = None;
223        // WP05 T025: the snapshot the headers, the detail pane and the next policy
224        // decision all read. Built here, once per refresh, from the corpus the board
225        // is built from — the same slice, so the two cannot disagree.
226        self.wip =
227            crate::wip::WipSnapshot::build(self.store.config.default_config(), &self.corpus.issues);
228        self.rebuild_board();
229        Ok(())
230    }
231
232    /// Apply an external change to the in-memory corpus and rebuild the board,
233    /// **without re-reading the issues tree** (issue #10 follow-up).
234    ///
235    /// `delta` comes from [`crate::watcher::FsWatcher::poll`] with paths
236    /// relative to the issues directory; [`Corpus::apply`] re-reads only those
237    /// files and drops the removed ones. A read error (e.g. a partially written
238    /// file) leaves the current corpus and board in place.
239    ///
240    /// The delta is narrowed to the current view first: with the archived
241    /// toggle off, an archived path is dropped rather than read, so an issue
242    /// archived outside `tix` leaves the active board instead of appearing on
243    /// it (the archived-inclusive [`App::ensure_search_corpus`] snapshot is
244    /// dropped and rebuilt from disk on the next search, as after any refresh).
245    pub fn refresh_from_disk(&mut self, delta: &FsDelta) -> Result<()> {
246        let delta = self.delta_for_view(delta);
247        self.corpus.apply(&self.store, &delta)?;
248        self.search_corpus = None;
249        self.rebuild_board();
250        Ok(())
251    }
252
253    /// Narrow `delta` to the paths the current view indexes (issue #10
254    /// follow-up).
255    ///
256    /// With the archived toggle off, a dot-prefixed file is not part of the
257    /// corpus, so a change to one is not a change to this view. With it on,
258    /// every path passes through and [`Corpus::apply`] judges `.md`/segment
259    /// rules.
260    pub(in crate::app) fn delta_for_view(&self, delta: &FsDelta) -> FsDelta {
261        if self.show_archived {
262            return delta.clone();
263        }
264        let visible = |path: &PathBuf| {
265            !path
266                .file_name()
267                .and_then(|name| name.to_str())
268                .is_some_and(|name| name.starts_with('.'))
269        };
270        FsDelta {
271            changed: delta
272                .changed
273                .iter()
274                .filter(|p| visible(p))
275                .cloned()
276                .collect(),
277            removed: delta
278                .removed
279                .iter()
280                .filter(|p| visible(p))
281                .cloned()
282                .collect(),
283        }
284    }
285}