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}