1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
#[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<()> {
let c = Corpus::build_with(&self.store, self.show_archived)?;
self.corpus = c;
// 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(),
}
}
}