Skip to main content

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}