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
278
279
280
281
282
283
284
285
286
287
288
#[allow(clippy::wildcard_imports)]
use super::*;
impl App {
/// The Library page's state (tix #1593), or a fresh default when the page is
/// not open. The renderer and the key handler both read it, and `mode` already
/// says whether the page is live, so a missing state is never an error.
pub fn library_state(&self) -> crate::ui::library::LibraryState {
self.library.clone().unwrap_or_default()
}
/// The Portfolio page's state (WP06), or a fresh default when the page is not
/// open. The renderer and the key handler both read it, and `mode` already says
/// whether the page is live, so a missing state is never an error.
pub fn portfolio_state(&self) -> crate::ui::portfolio::PortfolioState {
self.portfolio.clone().unwrap_or_default()
}
/// The service's project list as the **CLI** reads it (WP06).
///
/// Built from `store.list_all()` — archived issues **included** — because that
/// is the set `tix project list` and `tix project show` count over: the page's
/// figures and the CLI's are the same figures, and neither moves when the board's
/// archive toggle does. The result is cached ([`Self::refresh_portfolio_projects`])
/// and dropped when the page is left, so drawing the page per frame does not
/// re-read the issue tree (NFR-001).
///
/// Nothing is derived here: `member_count` comes from
/// [`crate::projects::list_projects`], which owns the membership rule (C-006).
pub fn projects_snapshot(&self) -> Vec<crate::projects::Project> {
self.portfolio_projects.clone().unwrap_or_default()
}
/// The Portfolio page's typed project health, as the **CLI** reads it (WP09).
///
/// Cached beside [`Self::projects_snapshot`] from the same snapshot, so the
/// page's progress and health figures are the values `tix project show` prints
/// and no surface derives a second one (C-006). Empty when the page is not open
/// or the read failed; the detail pane then renders the record alone.
pub fn portfolio_reports(
&self,
) -> std::collections::BTreeMap<crate::config::ProjectId, crate::projects::ProjectHealthReport>
{
self.portfolio_reports.clone().unwrap_or_default()
}
/// The Portfolio page's **Epics** tab rows, as the **CLI** reads them (WP06
/// T032).
///
/// The same [`crate::commands::view::epic_view`] value the epic view renders, so
/// the tab and the CLI cannot disagree about a figure (C-008). Empty when the
/// page is not open or the read failed; the tab then draws its explicit empty
/// state rather than a fabricated row.
pub fn portfolio_epics(&self) -> &[crate::commands::view::EpicViewEntry] {
self.portfolio_epics.as_deref().unwrap_or_default()
}
/// The Portfolio page's **Roadmap** tab projection (WP05), or `None` while the
/// page has not built one.
///
/// The **same** [`crate::roadmap::projection::project`] value `tix roadmap list`
/// renders, so the tab's rows, summaries and diagnostics are the CLI's (C-004,
/// C-007).
pub fn portfolio_roadmap(&self) -> Option<&crate::roadmap::RoadmapProjection> {
self.portfolio_roadmap.as_ref()
}
/// The Roadmap tab's normalized filter (WP05).
pub fn roadmap_filter(&self) -> &crate::roadmap::RoadmapFilter {
&self.roadmap_filter
}
/// The Roadmap tab's open overlay (WP05), if any.
pub fn roadmap_overlay(&self) -> Option<&crate::ui::roadmap::RoadmapOverlay> {
self.portfolio_roadmap_overlay.as_ref()
}
/// Whether the initiative registry is empty (for the tab's explicit empty
/// state).
pub fn roadmap_registry_is_empty(&self) -> bool {
self.store.config.initiatives.is_empty()
}
/// Re-read the Portfolio page's project list (WP06).
///
/// Called on mode entry and on every page key, so a project deleted from another
/// terminal is reflected the next time the user presses a key, while a frame
/// draw stays a pure read of the cache.
pub(in crate::app) fn refresh_portfolio_projects(&mut self) {
let issues = self.store.list_all().unwrap_or_default();
self.portfolio_projects = Some(crate::projects::list_projects(&self.store.config, &issues));
// The typed figures come from the **one** service read path the CLI uses
// (`project_reports` -> `ProjectIndex::health`), over the same `list_all`
// snapshot, so the page's figure equals `tix project show`'s for the same
// snapshot (WP09 item C, C-006). A read failure leaves the cache empty and
// the pane renders the record alone rather than a fabricated zero.
self.portfolio_reports = crate::commands::view::project_reports(&self.store, &issues)
.ok()
.map(|(_, reports)| reports);
// The Epics tab, from the **one** service read path the CLI's `tix epic
// list` / `tix epic show` use, over the **same** `list_all` snapshot as the
// project list above — archived issues included — so the tab's member counts
// and member lists equal the CLI's for the same snapshot (C-008). A read
// failure leaves the cache empty and the tab renders its explicit empty
// state.
let done_column = self
.store
.graph_source()
.map(|(_, column)| column)
.unwrap_or_default();
self.portfolio_epics =
crate::commands::view::epic_view_over(&self.store.config, &issues, &done_column).ok();
// The Roadmap tab joins the **same** `list_all` snapshot through the shared
// projection, so its rows equal `tix roadmap list`'s for the same snapshot
// (C-007). Only the active tab pays the join: a frame draw stays a pure read
// of the cache, and the other tabs never build a projection they do not show.
if self.portfolio_tab() == Some(crate::ui::portfolio::PortfolioTab::Roadmap) {
self.refresh_portfolio_roadmap(&issues);
}
}
/// Rebuild the Roadmap tab's projection from an already-read snapshot.
///
/// A refused filter (or a read failure) leaves the cache empty; the tab then
/// renders its explicit empty state rather than a stale or fabricated one. The
/// filter is validated again by the projection, so an invalid one can never be
/// installed in the first place.
pub(in crate::app) fn refresh_portfolio_roadmap(&mut self, issues: &[crate::model::Issue]) {
self.portfolio_roadmap = crate::ui::roadmap::projection_over(
&self.store.config,
&self.store,
issues,
&self.roadmap_filter,
)
.ok();
}
/// The Portfolio page's active tab, or `None` when the page is not open.
pub(in crate::app) fn portfolio_tab(&self) -> Option<crate::ui::portfolio::PortfolioTab> {
self.portfolio.as_ref().map(|state| state.tab)
}
/// The service's project list, read live (WP06).
///
/// For the **pickers** (the issue form's Projects row and the filter modal's
/// Project row), which are opened once per interaction rather than drawn per
/// frame: the list has to reflect what `tix project create` wrote a moment ago in
/// another terminal, and it is read from `store.list_all()` — archived issues
/// included — so it is the same set `tix project list` shows. The page's own rows
/// come from the cache above, for the reason that cache documents.
pub(in crate::app) fn live_projects(&self) -> Vec<crate::projects::Project> {
let issues = self.store.list_all().unwrap_or_default();
crate::projects::list_projects(&self.store.config, &issues)
}
/// The service's epic list, read live (WP06).
///
/// The epic twin of [`Self::live_projects`], for the pickers (the issue form's
/// Epics row and the filter modal's Epic row), which are opened once per
/// interaction rather than drawn per frame. The list is read from
/// `store.list_all()` — archived issues included — so a member's count matches
/// `tix epic list`'s.
pub(in crate::app) fn live_epics(&self) -> Vec<crate::epics::Epic> {
let issues = self.store.list_all().unwrap_or_default();
crate::epics::list_epics(&self.store.config, &issues)
}
/// The F10 Portfolio page's keys (WP06).
///
/// `j`/`k` and the arrows walk the active tab's rows, `Tab`/`Shift+Tab` switch
/// between the Projects and Epics registries (resetting the cursor, which indexes
/// the active tab's rows), `h`/`l` walk the selected epic's members on the Epics
/// tab, `Enter` opens the selected member issue's detail **directly** (FR-004),
/// `q`/`esc` return to the board, and `?` opens the help overlay. There is no
/// mutating key: the page is a reader, and every write lives on
/// `tix project …`/`tix epic …` and the issue form.
///
/// The cursor is clamped to the active registry on every key, because the
/// registry can change under the page: `tix project delete` in another terminal,
/// or a reload that drops a record this session did not touch.
pub(in crate::app) fn handle_portfolio_key(&mut self, key: KeyEvent) -> Result<Action> {
if !matches!(self.mode, Mode::Portfolio) {
return Ok(Action::None);
}
// Re-read first: the registry can change under the page (a project deleted
// from the CLI), and the cursor must clamp against the set this key sees.
self.refresh_portfolio_projects();
let tab = self
.portfolio
.as_ref()
.map_or(crate::ui::portfolio::PortfolioTab::Projects, |state| {
state.tab
});
let rows = match tab {
crate::ui::portfolio::PortfolioTab::Projects => self.projects_snapshot().len(),
crate::ui::portfolio::PortfolioTab::Epics => self.portfolio_epics().len(),
crate::ui::portfolio::PortfolioTab::Roadmap => self
.portfolio_roadmap()
.map_or(0, |projection| projection.initiatives.len()),
};
if let Some(state) = self.portfolio.as_mut() {
state.clamp_selected(rows);
}
// The selected epic's member count, for the member cursor's clamp and the
// `Enter` target. Empty on the Projects and Roadmap tabs.
let member_len = match tab {
crate::ui::portfolio::PortfolioTab::Projects
| crate::ui::portfolio::PortfolioTab::Roadmap => 0,
crate::ui::portfolio::PortfolioTab::Epics => {
let selected = self.portfolio_state().selected;
self.portfolio_epics()
.get(selected)
.map_or(0, |entry| entry.members.len())
}
};
if let Some(state) = self.portfolio.as_mut() {
state.clamp_member(member_len);
}
match key.code {
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => {
// The field's contract is "`Some` exactly while `mode` is
// `Mode::Portfolio`", so the page's own close clears it rather than
// leaving it for the next mode switch.
self.portfolio = None;
self.mode = Mode::Board;
}
KeyCode::Tab => {
if let Some(state) = self.portfolio.as_mut() {
state.switch_tab(1);
}
// The overlay belongs to the tab it was opened on, and the newly
// active tab's cache must be built before the next frame draws it.
self.portfolio_roadmap_overlay = None;
self.refresh_portfolio_projects();
}
KeyCode::BackTab => {
if let Some(state) = self.portfolio.as_mut() {
state.switch_tab(-1);
}
self.portfolio_roadmap_overlay = None;
self.refresh_portfolio_projects();
}
KeyCode::Up | KeyCode::Char('k') => {
if let Some(state) = self.portfolio.as_mut() {
state.move_selected(-1, rows);
}
}
KeyCode::Down | KeyCode::Char('j') => {
if let Some(state) = self.portfolio.as_mut() {
state.move_selected(1, rows);
}
}
KeyCode::Left | KeyCode::Char('h') => {
if let Some(state) = self.portfolio.as_mut() {
state.move_member(-1, member_len);
}
}
KeyCode::Right | KeyCode::Char('l') => {
if let Some(state) = self.portfolio.as_mut() {
state.move_member(1, member_len);
}
}
KeyCode::Enter => {
// The Epics tab's direct issue navigation (FR-004): `Enter` opens the
// selected member's own detail, never an epic pane. A no-op on the
// Projects tab (a reader) or when the selected epic has no members.
if tab == crate::ui::portfolio::PortfolioTab::Epics {
let state = self.portfolio_state();
let target = self
.portfolio_epics()
.get(state.selected)
.and_then(|entry| entry.members.get(state.member))
.map(|member| member.number);
if let Some(number) = target {
// Leave the page before opening the pane, so the page state's
// "`Some` exactly while the mode is Portfolio" contract holds.
self.portfolio = None;
let issue = self.store.resolve_issue(&number.to_string())?;
self.open_detail_root(issue)?;
}
}
}
KeyCode::Char('?') => self.open_help(Mode::Portfolio),
_ => {}
}
Ok(Action::None)
}
}