tix/app/portfolio.rs
1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4impl App {
5 /// The Library page's state (tix #1593), or a fresh default when the page is
6 /// not open. The renderer and the key handler both read it, and `mode` already
7 /// says whether the page is live, so a missing state is never an error.
8 pub fn library_state(&self) -> crate::ui::library::LibraryState {
9 self.library.clone().unwrap_or_default()
10 }
11
12 /// The Portfolio page's state (WP06), or a fresh default when the page is not
13 /// open. The renderer and the key handler both read it, and `mode` already says
14 /// whether the page is live, so a missing state is never an error.
15 pub fn portfolio_state(&self) -> crate::ui::portfolio::PortfolioState {
16 self.portfolio.clone().unwrap_or_default()
17 }
18
19 /// The service's project list as the **CLI** reads it (WP06).
20 ///
21 /// Built from `store.list_all()` — archived issues **included** — because that
22 /// is the set `tix project list` and `tix project show` count over: the page's
23 /// figures and the CLI's are the same figures, and neither moves when the board's
24 /// archive toggle does. The result is cached ([`Self::refresh_portfolio_projects`])
25 /// and dropped when the page is left, so drawing the page per frame does not
26 /// re-read the issue tree (NFR-001).
27 ///
28 /// Nothing is derived here: `member_count` comes from
29 /// [`crate::projects::list_projects`], which owns the membership rule (C-006).
30 pub fn projects_snapshot(&self) -> Vec<crate::projects::Project> {
31 self.portfolio_projects.clone().unwrap_or_default()
32 }
33
34 /// The Portfolio page's typed project health, as the **CLI** reads it (WP09).
35 ///
36 /// Cached beside [`Self::projects_snapshot`] from the same snapshot, so the
37 /// page's progress and health figures are the values `tix project show` prints
38 /// and no surface derives a second one (C-006). Empty when the page is not open
39 /// or the read failed; the detail pane then renders the record alone.
40 pub fn portfolio_reports(
41 &self,
42 ) -> std::collections::BTreeMap<crate::config::ProjectId, crate::projects::ProjectHealthReport>
43 {
44 self.portfolio_reports.clone().unwrap_or_default()
45 }
46
47 /// The Portfolio page's **Epics** tab rows, as the **CLI** reads them (WP06
48 /// T032).
49 ///
50 /// The same [`crate::commands::view::epic_view`] value the epic view renders, so
51 /// the tab and the CLI cannot disagree about a figure (C-008). Empty when the
52 /// page is not open or the read failed; the tab then draws its explicit empty
53 /// state rather than a fabricated row.
54 pub fn portfolio_epics(&self) -> &[crate::commands::view::EpicViewEntry] {
55 self.portfolio_epics.as_deref().unwrap_or_default()
56 }
57
58 /// The Portfolio page's **Roadmap** tab projection (WP05), or `None` while the
59 /// page has not built one.
60 ///
61 /// The **same** [`crate::roadmap::projection::project`] value `tix roadmap list`
62 /// renders, so the tab's rows, summaries and diagnostics are the CLI's (C-004,
63 /// C-007).
64 pub fn portfolio_roadmap(&self) -> Option<&crate::roadmap::RoadmapProjection> {
65 self.portfolio_roadmap.as_ref()
66 }
67
68 /// The Roadmap tab's normalized filter (WP05).
69 pub fn roadmap_filter(&self) -> &crate::roadmap::RoadmapFilter {
70 &self.roadmap_filter
71 }
72
73 /// The Roadmap tab's open overlay (WP05), if any.
74 pub fn roadmap_overlay(&self) -> Option<&crate::ui::roadmap::RoadmapOverlay> {
75 self.portfolio_roadmap_overlay.as_ref()
76 }
77
78 /// Whether the initiative registry is empty (for the tab's explicit empty
79 /// state).
80 pub fn roadmap_registry_is_empty(&self) -> bool {
81 self.store.config.initiatives.is_empty()
82 }
83
84 /// Re-read the Portfolio page's project list (WP06).
85 ///
86 /// Called on mode entry and on every page key, so a project deleted from another
87 /// terminal is reflected the next time the user presses a key, while a frame
88 /// draw stays a pure read of the cache.
89 pub(in crate::app) fn refresh_portfolio_projects(&mut self) {
90 let issues = self.store.list_all().unwrap_or_default();
91 self.portfolio_projects = Some(crate::projects::list_projects(&self.store.config, &issues));
92 // The typed figures come from the **one** service read path the CLI uses
93 // (`project_reports` -> `ProjectIndex::health`), over the same `list_all`
94 // snapshot, so the page's figure equals `tix project show`'s for the same
95 // snapshot (WP09 item C, C-006). A read failure leaves the cache empty and
96 // the pane renders the record alone rather than a fabricated zero.
97 self.portfolio_reports = crate::commands::view::project_reports(&self.store, &issues)
98 .ok()
99 .map(|(_, reports)| reports);
100 // The Epics tab, from the **one** service read path the CLI's `tix epic
101 // list` / `tix epic show` use, over the **same** `list_all` snapshot as the
102 // project list above — archived issues included — so the tab's member counts
103 // and member lists equal the CLI's for the same snapshot (C-008). A read
104 // failure leaves the cache empty and the tab renders its explicit empty
105 // state.
106 let done_column = self
107 .store
108 .graph_source()
109 .map(|(_, column)| column)
110 .unwrap_or_default();
111 self.portfolio_epics =
112 crate::commands::view::epic_view_over(&self.store.config, &issues, &done_column).ok();
113 // The Roadmap tab joins the **same** `list_all` snapshot through the shared
114 // projection, so its rows equal `tix roadmap list`'s for the same snapshot
115 // (C-007). Only the active tab pays the join: a frame draw stays a pure read
116 // of the cache, and the other tabs never build a projection they do not show.
117 if self.portfolio_tab() == Some(crate::ui::portfolio::PortfolioTab::Roadmap) {
118 self.refresh_portfolio_roadmap(&issues);
119 }
120 }
121
122 /// Rebuild the Roadmap tab's projection from an already-read snapshot.
123 ///
124 /// A refused filter (or a read failure) leaves the cache empty; the tab then
125 /// renders its explicit empty state rather than a stale or fabricated one. The
126 /// filter is validated again by the projection, so an invalid one can never be
127 /// installed in the first place.
128 pub(in crate::app) fn refresh_portfolio_roadmap(&mut self, issues: &[crate::model::Issue]) {
129 self.portfolio_roadmap = crate::ui::roadmap::projection_over(
130 &self.store.config,
131 &self.store,
132 issues,
133 &self.roadmap_filter,
134 )
135 .ok();
136 }
137
138 /// The Portfolio page's active tab, or `None` when the page is not open.
139 pub(in crate::app) fn portfolio_tab(&self) -> Option<crate::ui::portfolio::PortfolioTab> {
140 self.portfolio.as_ref().map(|state| state.tab)
141 }
142
143 /// The service's project list, read live (WP06).
144 ///
145 /// For the **pickers** (the issue form's Projects row and the filter modal's
146 /// Project row), which are opened once per interaction rather than drawn per
147 /// frame: the list has to reflect what `tix project create` wrote a moment ago in
148 /// another terminal, and it is read from `store.list_all()` — archived issues
149 /// included — so it is the same set `tix project list` shows. The page's own rows
150 /// come from the cache above, for the reason that cache documents.
151 pub(in crate::app) fn live_projects(&self) -> Vec<crate::projects::Project> {
152 let issues = self.store.list_all().unwrap_or_default();
153 crate::projects::list_projects(&self.store.config, &issues)
154 }
155
156 /// The service's epic list, read live (WP06).
157 ///
158 /// The epic twin of [`Self::live_projects`], for the pickers (the issue form's
159 /// Epics row and the filter modal's Epic row), which are opened once per
160 /// interaction rather than drawn per frame. The list is read from
161 /// `store.list_all()` — archived issues included — so a member's count matches
162 /// `tix epic list`'s.
163 pub(in crate::app) fn live_epics(&self) -> Vec<crate::epics::Epic> {
164 let issues = self.store.list_all().unwrap_or_default();
165 crate::epics::list_epics(&self.store.config, &issues)
166 }
167
168 /// The F10 Portfolio page's keys (WP06).
169 ///
170 /// `j`/`k` and the arrows walk the active tab's rows, `Tab`/`Shift+Tab` switch
171 /// between the Projects and Epics registries (resetting the cursor, which indexes
172 /// the active tab's rows), `h`/`l` walk the selected epic's members on the Epics
173 /// tab, `Enter` opens the selected member issue's detail **directly** (FR-004),
174 /// `q`/`esc` return to the board, and `?` opens the help overlay. There is no
175 /// mutating key: the page is a reader, and every write lives on
176 /// `tix project …`/`tix epic …` and the issue form.
177 ///
178 /// The cursor is clamped to the active registry on every key, because the
179 /// registry can change under the page: `tix project delete` in another terminal,
180 /// or a reload that drops a record this session did not touch.
181 pub(in crate::app) fn handle_portfolio_key(&mut self, key: KeyEvent) -> Result<Action> {
182 if !matches!(self.mode, Mode::Portfolio) {
183 return Ok(Action::None);
184 }
185 // Re-read first: the registry can change under the page (a project deleted
186 // from the CLI), and the cursor must clamp against the set this key sees.
187 self.refresh_portfolio_projects();
188 let tab = self
189 .portfolio
190 .as_ref()
191 .map_or(crate::ui::portfolio::PortfolioTab::Projects, |state| {
192 state.tab
193 });
194 let rows = match tab {
195 crate::ui::portfolio::PortfolioTab::Projects => self.projects_snapshot().len(),
196 crate::ui::portfolio::PortfolioTab::Epics => self.portfolio_epics().len(),
197 crate::ui::portfolio::PortfolioTab::Roadmap => self
198 .portfolio_roadmap()
199 .map_or(0, |projection| projection.initiatives.len()),
200 };
201 if let Some(state) = self.portfolio.as_mut() {
202 state.clamp_selected(rows);
203 }
204 // The selected epic's member count, for the member cursor's clamp and the
205 // `Enter` target. Empty on the Projects and Roadmap tabs.
206 let member_len = match tab {
207 crate::ui::portfolio::PortfolioTab::Projects
208 | crate::ui::portfolio::PortfolioTab::Roadmap => 0,
209 crate::ui::portfolio::PortfolioTab::Epics => {
210 let selected = self.portfolio_state().selected;
211 self.portfolio_epics()
212 .get(selected)
213 .map_or(0, |entry| entry.members.len())
214 }
215 };
216 if let Some(state) = self.portfolio.as_mut() {
217 state.clamp_member(member_len);
218 }
219 match key.code {
220 KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => {
221 // The field's contract is "`Some` exactly while `mode` is
222 // `Mode::Portfolio`", so the page's own close clears it rather than
223 // leaving it for the next mode switch.
224 self.portfolio = None;
225 self.mode = Mode::Board;
226 }
227 KeyCode::Tab => {
228 if let Some(state) = self.portfolio.as_mut() {
229 state.switch_tab(1);
230 }
231 // The overlay belongs to the tab it was opened on, and the newly
232 // active tab's cache must be built before the next frame draws it.
233 self.portfolio_roadmap_overlay = None;
234 self.refresh_portfolio_projects();
235 }
236 KeyCode::BackTab => {
237 if let Some(state) = self.portfolio.as_mut() {
238 state.switch_tab(-1);
239 }
240 self.portfolio_roadmap_overlay = None;
241 self.refresh_portfolio_projects();
242 }
243 KeyCode::Up | KeyCode::Char('k') => {
244 if let Some(state) = self.portfolio.as_mut() {
245 state.move_selected(-1, rows);
246 }
247 }
248 KeyCode::Down | KeyCode::Char('j') => {
249 if let Some(state) = self.portfolio.as_mut() {
250 state.move_selected(1, rows);
251 }
252 }
253 KeyCode::Left | KeyCode::Char('h') => {
254 if let Some(state) = self.portfolio.as_mut() {
255 state.move_member(-1, member_len);
256 }
257 }
258 KeyCode::Right | KeyCode::Char('l') => {
259 if let Some(state) = self.portfolio.as_mut() {
260 state.move_member(1, member_len);
261 }
262 }
263 KeyCode::Enter => {
264 // The Epics tab's direct issue navigation (FR-004): `Enter` opens the
265 // selected member's own detail, never an epic pane. A no-op on the
266 // Projects tab (a reader) or when the selected epic has no members.
267 if tab == crate::ui::portfolio::PortfolioTab::Epics {
268 let state = self.portfolio_state();
269 let target = self
270 .portfolio_epics()
271 .get(state.selected)
272 .and_then(|entry| entry.members.get(state.member))
273 .map(|member| member.number);
274 if let Some(number) = target {
275 // Leave the page before opening the pane, so the page state's
276 // "`Some` exactly while the mode is Portfolio" contract holds.
277 self.portfolio = None;
278 let issue = self.store.resolve_issue(&number.to_string())?;
279 self.open_detail_root(issue)?;
280 }
281 }
282 }
283 KeyCode::Char('?') => self.open_help(Mode::Portfolio),
284 _ => {}
285 }
286 Ok(Action::None)
287 }
288}