Skip to main content

tix/
app.rs

1use std::cell::{Cell, RefCell};
2use std::collections::BTreeSet;
3use std::io;
4use std::path::{Path, PathBuf};
5use std::time::{Duration, Instant};
6
7use crossterm::event::{
8    self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind,
9    KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
10};
11use crossterm::execute;
12use crossterm::terminal::{
13    EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
14};
15use ratatui::Terminal;
16use ratatui::backend::CrosstermBackend;
17use ratatui::layout::{Position, Rect};
18use ratatui::style::{Color, Modifier, Style};
19use ratatui::text::{Line, Span};
20use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
21
22use crate::assignee::{git_identity, resolve_me};
23use crate::commands::{self, mv};
24use crate::config::{BoardConfiguration, Config};
25use crate::error::{Result, TixError};
26use crate::keybindings::{EffectiveBindings, InputContext, TuiAction};
27use crate::model::{Issue, Priority, Status, today};
28use crate::planning::Planner;
29use crate::search::{self, Corpus, FilterField};
30use crate::storage::{IssueSelector, Store};
31use crate::templates::{TemplateInput, TemplateScope, TemplateSelection, resolve};
32
33use crate::edit_problems::{self, EditFix, EditProblem};
34use crate::editor;
35use crate::ui::action_menu::{ActionGroup, ActionMenu, ActionMenuItem, IssueAction, MenuEvent};
36use crate::ui::board::{self, BoardState, HitTarget};
37use crate::ui::commandbar;
38use crate::ui::date_picker::{DatePicker, DatePickerEvent, DatePickerHit};
39use crate::ui::detail::{self, DetailState};
40use crate::ui::dialog::{DialogEvent, RolloverDialog};
41use crate::ui::form::{self, FormState, NewIssueTarget};
42use crate::ui::nav::{NavDisplay, TopMode};
43use crate::ui::pickers::{DropdownState, PickerEvent, PickerItem, PickerMode, SelectionModel};
44use crate::ui::planning::{
45    PlanningForm, PlanningKind, PlanningModal, PlanningPrompt, PlanningRow, PlanningSegment,
46};
47use crate::ui::settings::{
48    ConfigLevel, ConfigSection, SettingsModal, SettingsPrompt, board_rows_len,
49};
50use crate::ui::tabs::BoardTabs;
51use crate::ui::triage::{TriageControl, TriageDropdownKind};
52use crate::ui::views::{ViewFeedback, ViewRow, ViewsModal, ViewsPrompt, scope_summary};
53use crate::views::divergence::{ActiveViewMarker, ViewDivergence, ViewSurface};
54use crate::views::plan::{ViewPlan, ViewVisibility};
55use crate::views::registry::{ViewDiagnostic, ViewId, ViewScope, ViewWriteError};
56use crate::views::service::{DeleteConfirmed, ViewDefinition, ViewService};
57use crate::watcher::FsDelta;
58
59mod bulk;
60mod feedback;
61mod mentions;
62mod mode;
63mod state;
64mod triage;
65pub use feedback::ActionFeedback;
66pub use mode::Mode;
67pub use state::{Action, LinkStep, MoveTarget};
68use state::{
69    CandidateAction, CapacityCache, Drag, HelpMetrics, HitRegion, MAX_DETAIL_NAV, MouseDown,
70    MouseState, PendingEdit, PendingMove, ViewSession, WHEEL_ROWS,
71};
72
73// ---------------------------------------------------------------------------
74// WP05: the F7 Stats page's Time tab, and the time-entry form
75// ---------------------------------------------------------------------------
76
77/// Append `current` to a picker's options when it is non-empty and not already
78/// listed, so a criterion that is already set stays selectable even when nothing
79/// currently produces its value.
80fn push_current_option(options: &mut Vec<PickerItem>, current: &str) {
81    if current.is_empty() || options.iter().any(|option| option.id == current) {
82        return;
83    }
84    options.push(PickerItem {
85        id: current.to_string(),
86        label: current.to_string(),
87    });
88}
89
90/// The shared resolver input for the TUI's create form (WP05/T025).
91///
92/// Every row the form has maps one-for-one onto the resolver's documented create
93/// fields, so a template can only ever pre-fill the normal create model. A
94/// **blank** row maps to `None` — "not provided" — rather than `Some("")`, so an
95/// omitted value does not override a template default; this is the same meaning
96/// `tix create` gives a flag that was left off, which is what keeps the two
97/// surfaces equivalent. The rows the form does not have (`files`,
98/// `related_issues`, `type`) are `None` here and are filled from the template's
99/// defaults by the resolver, so no template field is silently dropped.
100fn template_input_from_form(form: &FormState) -> TemplateInput {
101    fn present(value: &str) -> Option<String> {
102        (!value.trim().is_empty()).then(|| value.to_string())
103    }
104    TemplateInput {
105        title: present(&form.title),
106        // The body is content, not a whitespace-trimmed flag: only a truly empty
107        // body is "not provided", so a Markdown body's blank lines survive.
108        body: (!form.body.is_empty()).then(|| form.body.clone()),
109        status: present(&form.status),
110        priority: present(&form.priority),
111        assignee: present(&form.assignee),
112        labels: present(&form.labels),
113        files: None,
114        estimate: present(&form.estimate),
115        cycles: present(&form.cycles),
116        milestones: present(&form.milestones),
117        reviewer: present(&form.reviewer),
118        start_date: present(&form.start_date),
119        due_date: present(&form.due_date),
120        parent: present(&form.parent),
121        related_issues: None,
122        type_: None,
123    }
124}
125
126/// Independent toggles are what a TUI session's state *is* — the mode lives in `Mode`,
127/// and the flags beside it (archived shown, help line open, one-shot exit) are not
128/// states a machine would model, they are switches the user throws. Four is not a
129/// design smell here; a struct with a mode enum and a handful of toggles is the shape.
130#[allow(clippy::struct_excessive_bools)]
131pub struct App {
132    pub mode: Mode,
133    /// The active top-level mode of the shell (tui-mode-navigation). Session-only:
134    /// it is derived from the current [`Mode`] by [`App::sync_top_mode`] and never
135    /// persisted.
136    pub top_mode: TopMode,
137    /// How the vertical mode navigation is displayed (`Off`/`Small`/`Default`).
138    /// Session-only, cycled by `toggle_navigation`; never persisted.
139    pub nav_display: NavDisplay,
140    /// Which rail row owns the keyboard, as an index into [`App::nav_rows`]
141    /// (trunks and their limbs in draw order); `None` means the mode's content
142    /// owns it. Session-only. Entered with `h`/`Left` when the active mode has
143    /// no leftward target, left with `l`/`Right`/`Esc` (see
144    /// [`App::handle_nav_key`]).
145    nav_focus: Option<usize>,
146    /// Whether the Config surface is currently standing in for the F5
147    /// **Milestones** mode. The Milestones mode reuses the Config surface pinned
148    /// to [`ConfigSection::Milestones`] rather than growing a second view; this
149    /// flag is what makes the rail highlight Milestones (not Config), gives the
150    /// mode its own [`InputContext::Milestones`] keys, and hides the Config
151    /// section rail. Session-only, never persisted.
152    pub milestone_mode: bool,
153    pub board: BoardState,
154    /// Resolved tag/type/milestone colors for this session (tix #1530). Built
155    /// once at startup from the tag store and the config; the board and detail
156    /// renderers read it.
157    pub theme: crate::ui::color::Theme,
158    pub detail: Option<DetailState>,
159    pub form: Option<FormState>,
160    pub confirm_delete_target: Option<String>,
161    pub store: Store,
162    pub status_msg: Option<String>,
163    /// The last advertised action this mode refused, with the remedy that would
164    /// make it possible. Cleared when the next key is accepted, so a refusal is
165    /// visible on the frame that produced it and never leaks into a later mode.
166    pub action_feedback: Option<ActionFeedback>,
167    pub quit: bool,
168    pub bindings: EffectiveBindings,
169    /// The raw bytes of `tix.toml` as last read, so a hand-edit during the
170    /// session is adopted instead of linted against a stale configuration.
171    config_snapshot: Option<Vec<u8>>,
172    /// The active named board configuration index into `Config::configs()`
173    /// (session state only — never persisted). `rebuild_board`/`apply_filter`
174    /// must not reset it.
175    pub active_config: usize,
176    /// Persistent tabs bar of all named boards (DM-01M2MH8J01DZHV3JMT76XX38KX).
177    pub tabs: BoardTabs,
178    filter: Option<String>,
179    corpus: Corpus,
180    /// A second corpus snapshot that **includes archived** issues, built
181    /// lazily the first time a `/` search runs and reused keystroke to
182    /// keystroke (tix #1498).
183    ///
184    /// `corpus` above is the active set, built from [`crate::storage::Store::list`]
185    /// so a repository full of archived files opens cheaply (tix #1492). Search
186    /// must still find an archived issue — the whole point of "always search
187    /// archived" — so it reads this snapshot instead, and only once a query is
188    /// non-empty. [`App::refresh_corpus`] drops it, so the next search rebuilds
189    /// it from whatever is on disk now. `None` also means "not yet built"; a
190    /// build error leaves it `None` and the caller falls back to `corpus`, so a
191    /// transient read failure degrades to an archived-blind search rather than
192    /// no search at all.
193    search_corpus: Option<Corpus>,
194    /// Whether the board is showing **archived** issues too (tix #1492).
195    ///
196    /// Off by default, and that default is the point: the board and the corpus are
197    /// built from [`crate::storage::Store::list`], which drops a dot-prefixed file
198    /// from the directory entry without opening it, so a repository with thousands of
199    /// archived issues costs nothing to open. Toggling this on is the "reload them on
200    /// demand" half — `refresh_corpus` re-reads the issue set with the archived files
201    /// included and rebuilds the board around it.
202    pub show_archived: bool,
203    /// Whether the board draws each issue's sub-issues beneath it (tix #1399).
204    ///
205    /// **On** by default. Session-only, like [`App::show_archived`] — it is a view
206    /// preference, not a setting written back to `tix.toml`. The `u` toggle flips it
207    /// and rebuilds the board, which reorders each column so a sub-issue follows its
208    /// parent offset one ASCII column (see `BoardState::build_with`); no store
209    /// re-read is needed because the children are already in the issue set.
210    pub show_children: bool,
211    /// How many rows the keybindings overlay is scrolled down (tix #1441).
212    ///
213    /// Reset when the overlay opens, so `?` always starts at the top: a help screen
214    /// that reappears halfway down, from a scroll three sessions ago, would hide the
215    /// first bindings from exactly the person who needed them.
216    help_scroll: u16,
217    /// The keybindings overlay's `/` query, and whether the input line has the
218    /// keyboard (tix #1496).
219    ///
220    /// Cleared when the overlay opens, like the scroll offset and for the same reason:
221    /// a help screen that reappeared filtered by a query from ten minutes ago would
222    /// hide the binding the reader opened it to find.
223    help_query: String,
224    help_searching: bool,
225    /// The overlay's last-rendered geometry, recorded by its own draw (tix #1441).
226    ///
227    /// A `Cell` for the same reason `frame_hit_regions` is a `RefCell`: the renderer
228    /// takes `&App`, and it is the only thing that knows how many rows fit.
229    help_metrics: std::cell::Cell<HelpMetrics>,
230    /// Cards one screen of the active board column holds, as of the last draw
231    /// (tix #1442 follow-up).
232    ///
233    /// Reported by the board renderer, which is the only thing that knows the column's
234    /// height and each card's rendered height; a `Cell` for the same reason
235    /// `help_metrics` is one — the renderer holds `&App`.
236    board_page: std::cell::Cell<usize>,
237    pub filter_modal: Option<crate::ui::filter_modal::FilterModal>,
238    /// Config mode state (F12, `s`, `b`): the active section and the dashboards
239    /// row cursor. `Some` exactly while [`Mode::Settings`] (and its planning
240    /// sub-forms) is active.
241    pub settings: Option<SettingsModal>,
242    /// Planning cursor: the `m` management surface's list state, and the same
243    /// cursor the Config mode's cycles/milestones sections drive (one state, not
244    /// two).
245    pub planning: Option<PlanningModal>,
246    /// Create/edit form for a planning item.
247    pub planning_form: Option<PlanningForm>,
248    /// Open calendar date picker (overlaid on the form that opened it).
249    pub date_picker: Option<DatePicker>,
250    /// Committed structured filter (kept when the modal is closed). The live
251    /// in-progress criteria come from `filter_modal` while it is open.
252    structured: search::StructuredFilter,
253    rollover_dialog: Option<RolloverDialog>,
254    form_picker: Option<FormPicker>,
255    /// The filter modal's open value picker, `Some` exactly while a criterion's
256    /// list overlays the modal. Cleared whenever the modal closes.
257    filter_picker: Option<FilterPicker>,
258    /// One-shot render (`tix render`): suppresses the keyboard hint bar.
259    one_shot: bool,
260    /// Click targets recorded by widgets during the current draw; cleared at
261    /// the start of every draw and resolved by [`App::resolve_region`] when a
262    /// pointer event arrives (D3). `RefCell` lets the &-borrowing render pass
263    /// record regions while `handle_mouse` resolves them later (D6).
264    frame_hit_regions: RefCell<Vec<HitRegion>>,
265    /// Mouse click/drag state (D4).
266    mouse: MouseState,
267    /// The issue action menu: `Some` exactly while `mode` is
268    /// [`Mode::ActionMenu`].
269    pub action_menu: Option<ActionMenu>,
270    /// The move waiting on one of its prompts; the answers survive every
271    /// re-entry so no prompt discards another's.
272    pending_move: Option<PendingMove>,
273    /// Where a time form opened from the action menu returns: the board, not the
274    /// Stats page that owns the form's usual entry point.
275    time_return: Option<Mode>,
276    /// The batch modal (WP07), `Some` exactly while `mode` is
277    /// [`Mode::BulkModal`].
278    pub bulk: Option<crate::ui::bulk::BulkModal>,
279    /// The marked set a content action (comment, health, parent) covers, or
280    /// `None` when the action is about the detail pane's single issue. Set when
281    /// the action menu opens on a card that is part of a multi-selection, and
282    /// cleared when the prompt submits or cancels.
283    pub bulk_action_targets: Option<Vec<u32>>,
284    /// The saved-views modal (WP05), `Some` exactly while `mode` is
285    /// [`Mode::Views`]. Its rows mirror the registry; its cursor, prompt, and
286    /// feedback are its own.
287    pub views: Option<ViewsModal>,
288    /// The saved view the live board is associated with, session-only. `None`
289    /// means "no active view" — which the divergence report distinguishes from
290    /// "the active view was deleted" (C-006/data-model §5).
291    view_session: Option<ViewSession>,
292    /// The triage inbox (WP06), `Some` exactly while `mode` is
293    /// [`Mode::TriageInbox`]. Its cursor and selection are its own — see the
294    /// note on that mode.
295    pub triage: Option<crate::ui::triage::TriageState>,
296    /// The F3 Cycle mode's cursor (tui-cycle-mode), `Some` exactly while `mode`
297    /// is [`Mode::Cycle`]. Its selection is its own, like the triage inbox's.
298    pub cycle_state: Option<crate::ui::cycle::CycleState>,
299    /// The F6 Repos mode's row cursor (tix #1529). Session-only; reset when the
300    /// mode is entered.
301    pub repos_cursor: usize,
302    /// The due-warning overlay (WP07), `Some` exactly while `mode` is
303    /// [`Mode::RecurringWarning`].
304    pub recurring_warning: Option<crate::ui::recurring_warning::RecurringWarning>,
305    /// The per-issue mention notice, `Some` exactly while it is open.
306    pub mentions: Option<crate::ui::mentions::MentionsView>,
307    /// The all-issues mentions view, `Some` exactly while it is open.
308    pub mentions_view: Option<crate::ui::mentions::MentionsView>,
309    /// How many unread mentions the local user holds — the indicator row's figure.
310    ///
311    /// Cached because counting reads the mention store, and a render pass must not
312    /// touch the disk. Recomputed by [`App::refresh_mentions`], which runs at
313    /// startup, on every refresh, and after every mention action, so the indicator
314    /// cannot disagree with the rows a notice is showing.
315    pub unread_mentions: usize,
316    /// The mention bodies that address the local user, from git (design D6), cached
317    /// beside [`App::unread_mentions`] so the two are always the same snapshot.
318    pub mention_candidates: Vec<String>,
319    /// The board's WIP state, derived once per refresh (WP05 T025). Empty for a
320    /// board with no limits, and rebuilt from the corpus rather than re-read: the
321    /// figures a header shows are the ones the policy judged the move against.
322    pub wip: crate::wip::WipSnapshot,
323    /// The override prompt (WP05 T026), `Some` exactly while `mode` is
324    /// [`Mode::WipOverride`].
325    pub wip_override: Option<crate::ui::wip_override::WipOverridePrompt>,
326
327    /// The health form being filled in, when one is open.
328    pub health_form: Option<HealthForm>,
329    /// The `F7` Stats page's active tab (WP05 T026). A tab on a page that already
330    /// ships — not a mode — exactly as `crate::ui::roadmap` documents for the `F10`
331    /// Portfolio page.
332    pub stats_tab: StatsTab,
333    /// The Time tab's one read and its session state, `Some` exactly while the tab has
334    /// been opened. Rebuilt on entering the tab and after every mutation, so a render
335    /// never reads the disk.
336    pub stats_time: Option<StatsTimeState>,
337    /// The Burndown tab's selections and its one read (WP06 T029–T032), `Some` exactly
338    /// while the tab has been opened.
339    ///
340    /// The document inside it is the shared [`crate::analytics::chart::ChartExport`] the
341    /// CLI's verbs read, rebuilt when the tab is opened and after every control — never
342    /// during a render, and never from a second fold of its own.
343    pub stats_burndown: Option<crate::ui::analytics::BurndownState>,
344    /// The time-entry form being filled in, when one is open.
345    pub time_form: Option<TimeForm>,
346    /// The Time tab's filter editor, when it is open.
347    pub time_filter_form: Option<TimeFilterForm>,
348    /// The `F7` Stats page's row cursor.
349    pub stats_cursor: usize,
350    /// The health document for the whole repository (mission
351    /// `project-issue-health-status-01M3C8HX`, WP06): the same DTO the CLI renders,
352    /// so the card badge, the detail block and the `F7` page cannot describe a
353    /// different snapshot from `tix health` (C-007).
354    ///
355    /// Refreshed wherever the board is rebuilt, and never computed per keystroke: the
356    /// document is one pass over the issues, and a render must not read the disk.
357    pub health: Option<crate::health::PortableHealth>,
358    /// The override the user authorized, while a move is still in flight.
359    ///
360    /// A confirmed override does not always finish the move: a move into a
361    /// completion column still has to ask for a commit hash, and that prompt is a
362    /// **separate** writer. Holding the request here is what keeps the two from
363    /// drifting — the second writer finds the same authorization rather than a
364    /// second evaluation that could disagree with the first.
365    pending_wip_override: Option<crate::wip::OverrideRequest>,
366    /// The F8 Schedule page's state: the active tab, its cursor and any reason
367    /// prompt (tix #1593/#181), `Some` exactly while `mode` is [`Mode::Schedule`].
368    pub schedule: Option<crate::ui::schedule::ScheduleState>,
369    /// The last read-only cycle-schedule evaluation (WP07 T035), replaced by the
370    /// startup pass and by every refresh.
371    ///
372    /// A **value**, not a writer: [`crate::cycle_schedule::evaluate::evaluate`] is
373    /// pure, and the refresh only reads the records and the schedule registry to
374    /// build the index. Nothing here closes, generates, or advances a cursor — the
375    /// mutation paths are the confirmed requests in
376    /// [`App::begin_cycle_schedule_action`]/[`App::generate_selected_cycle_schedule`].
377    cycle_schedule_warnings: Option<crate::cycle_schedule::evaluate::CycleEvaluation>,
378    /// The capacity read and the display data derived from it (WP06).
379    ///
380    /// `None` when the project's schedule index could not be read at the last refresh;
381    /// the Capacity tab then says so rather than claiming nothing is configured.
382    capacity: Option<CapacityCache>,
383    /// The F11 Library page's state (tix #1593), `Some` exactly while `mode` is
384    /// [`Mode::Library`]: the active tab and its row cursor. Session-only, reset
385    /// when the mode is entered.
386    pub library: Option<crate::ui::library::LibraryState>,
387    /// The F10 Portfolio page's state (WP06), `Some` exactly while `mode` is
388    /// [`Mode::Portfolio`]: the active tab and its row cursor. Session-only, reset
389    /// when the mode is entered.
390    pub portfolio: Option<crate::ui::portfolio::PortfolioState>,
391    /// The Portfolio page's project list, cached at mode entry and after every key
392    /// (WP06).
393    ///
394    /// The CLI the page mirrors (`tix project list` / `tix project show`) reads
395    /// `store.list_all()`, **archived issues included**, so the page must read the
396    /// same set or its member counts would differ from the CLI's and would change
397    /// when the board's archive toggle moved. Reading that set per frame is the cost
398    /// this cache exists to avoid (the corpus excludes archived issues, so it is not
399    /// a substitute); it is dropped with the page's other state when the mode is
400    /// left.
401    portfolio_projects: Option<Vec<crate::projects::Project>>,
402    /// The Portfolio page's typed project health, cached beside the list (WP09).
403    ///
404    /// The page must render "exactly the data the CLI exposes", so the detail pane
405    /// shows the same progress and health figures `tix project show` prints. Those
406    /// figures are the service's typed value ([`crate::projects::ProjectHealthReport`]),
407    /// read once per refresh from the shared `project_reports` path rather than
408    /// recomputed per frame (C-006); `None` (page not open, or no reports) makes the
409    /// page render the record alone.
410    portfolio_reports: Option<
411        std::collections::BTreeMap<crate::config::ProjectId, crate::projects::ProjectHealthReport>,
412    >,
413    /// The Portfolio page's **Epics** tab rows, cached beside the project list (WP06
414    /// T032).
415    ///
416    /// Read through [`crate::commands::view::epic_view`] — the same service path the
417    /// epic view and `tix show` use — over the same `store.list_all()` snapshot as
418    /// the project list, so the tab's rows and its detail are the values the CLI
419    /// exposes and no surface derives a second one (C-008). Dropped with the page's
420    /// other state when the mode is left.
421    portfolio_epics: Option<Vec<crate::commands::view::EpicViewEntry>>,
422    /// The Portfolio page's **Roadmap** tab projection (WP05), cached beside the
423    /// page's other reads.
424    ///
425    /// The shared [`crate::roadmap::RoadmapProjection`] the CLI's `tix roadmap list`
426    /// renders — the same join, predicate and sort — so the tab and the CLI cannot
427    /// disagree about a row, a summary value or a diagnostic (C-004, C-007). Rebuilt
428    /// per refresh from the snapshot [`Self::refresh_portfolio_projects`] already
429    /// read; dropped with the page's other state when the mode is left.
430    portfolio_roadmap: Option<crate::roadmap::RoadmapProjection>,
431    /// The Roadmap tab's normalized filter (WP05), the **one** DTO the CLI builds
432    /// too. Session-only: reset when the page is entered, and never persisted.
433    pub roadmap_filter: crate::roadmap::RoadmapFilter,
434    /// The Roadmap tab's open overlay (WP05): the filter editor or an initiative
435    /// action, `Some` exactly while one is open. Cleared with the page's other state
436    /// and on a tab change, so its text never leaks onto another tab.
437    pub portfolio_roadmap_overlay: Option<crate::ui::roadmap::RoadmapOverlay>,
438    /// The mode the planning-management modal returns to when it closes: the
439    /// board when opened with `m` there, the F3 Cycle mode when opened from it
440    /// (tui-cycle-mode / planning-management). Session-only.
441    planning_return: Mode,
442    /// Column currently shown as the drag-drop target while a card is held.
443    drag_preview_col: Option<usize>,
444    /// The action of the innermost hit region under the pointer as of the last
445    /// `Moved` event, so any widget can style itself when hovered (the vertical
446    /// mode navigation in every mode, the Config section rail, dropdown rows).
447    /// `None` when the pointer is not over a recorded region.
448    hover_action: Option<Action>,
449    /// The terminal size of the most recent draw, remembered so pointer
450    /// coordinates can be mapped onto the board render area (D3).
451    frame_size: Cell<Rect>,
452    /// The editor round trip currently in flight, if any (tix #1510). `Some` only
453    /// between the snapshot taken before the editor is launched and the decision
454    /// [`App::after_editor`] makes when it exits, so it is also the signal that a
455    /// pre-edit snapshot exists to compare against.
456    pending_edit: Option<PendingEdit>,
457    /// The detail pane's back stack, bounded (tix #1513).
458    ///
459    /// A pane is pushed when the reader opens a **related** entry from an open
460    /// pane, so `Backspace` can return to where the jump started. Snapshotting the
461    /// whole [`DetailState`] rather than a filename is what keeps the stack honest
462    /// for a `Path` entry: the previous pane may itself have been an external,
463    /// read-only file with no store entry to re-open.
464    ///
465    /// Bounded because following links is unbounded by nature — a chain of
466    /// cross-repo references can be arbitrarily long, and a session that walks a
467    /// thousand of them must not grow the stack without limit. The **oldest** entry
468    /// is dropped, so the back key always walks back toward what the reader saw
469    /// most recently.
470    nav_stack: Vec<DetailState>,
471    /// The open related-issues picker, `Some` exactly while it overlays the detail
472    /// pane (tix #1513). `input_context` reports [`InputContext::RelatedPicker`]
473    /// while it is `Some`, exactly as `form_picker` does for the form.
474    related_picker: Option<DropdownState>,
475    /// The startup tips modal (tix #1632), `Some` exactly while `mode` is
476    /// [`Mode::Tips`]. Its index is into the embedded [`crate::tips::all`] slice.
477    pub tips: Option<crate::ui::tips::TipsState>,
478}
479
480impl App {
481    /// Record a disclosure raised by a write, alongside whatever confirmation the
482    /// action already produced.
483    ///
484    /// Cycle-4 F4. The event loop drains at the *top* of the loop body, so a
485    /// disclosure raised by `handle_key` at the bottom would overwrite that
486    /// action's confirmation message before any frame ever drew it — the user
487    /// moved an issue and never saw "Moved #1 to in progress". Composing is the
488    /// fix: both facts are true, and both are worth showing.
489    ///
490    /// The disclosure is appended rather than prepended so the confirmation reads
491    /// first, which is the order the user caused them in. The existing message
492    /// is left untouched, so this cannot swallow anything either.
493    pub fn note_disclosure(&mut self, disclosure: String) {
494        // Disclosure first, then the confirmation. Order alone does **not** make
495        // it fit: the status area is a single unwrapped row, so at 80 columns a
496        // disclosure plus a long filename is cut regardless of order. Cycle 6
497        // measured this and found the claim previously recorded here — that
498        // reordering made the actionable tail survive — to be false at 80, 100
499        // and 120 columns.
500        //
501        // What actually helps is that the disclosure now *leads with the action*
502        // ("remove `links:` by hand to silence"), so the instruction is the part
503        // that survives a long filename, plus an ellipsis at the render site so a
504        // cut is visible rather than silent. The full text remains in
505        // `tix lint`, which is the durable channel for it.
506        self.status_msg = Some(match self.status_msg.take() {
507            Some(existing) if !existing.is_empty() => format!("{disclosure} — {existing}"),
508            _ => disclosure,
509        });
510    }
511
512    #[allow(clippy::too_many_lines)]
513    pub fn new(config: &Config) -> Result<Self> {
514        let mut store = Store::open(config)?;
515        // A session started from the Repos mode inherits the parent's project
516        // root in `TIX_PARENT_REPO` (tix #1529). Adopt it when no explicit
517        // `parent_repo` is configured, so the way back exists even in a child
518        // that never declared one.
519        let started_from_parent = std::env::var_os("TIX_PARENT_REPO").is_some();
520        if store.config.parent_repo.is_none() {
521            if let Some(parent) = std::env::var_os("TIX_PARENT_REPO") {
522                store.config.parent_repo = Some(PathBuf::from(parent));
523            }
524        }
525        // The bytes `tix.toml` had at startup, so a hand-edit made while the TUI
526        // is open can be noticed and adopted (see `reload_config_if_changed`).
527        let config_snapshot = store
528            .config_path()
529            .ok()
530            .and_then(|path| std::fs::read(path).ok());
531        // Resolve the color theme once (tix #1530): tag colors from the tag
532        // store, type colors from the config, milestone colors from their files.
533        let theme = theme_for(&store.config);
534        let tabs = BoardTabs::new(config.configs().map(|(name, _)| name.to_string()).collect());
535        // One enumeration for the whole startup (tix #1518). Before this, the
536        // board, the corpus and the rollover plan each walked the tree — the
537        // board and corpus once each, the rollover plan once per expired cycle
538        // — so a cold start read and parsed every issue file three or four
539        // times over. All three now build from this single slice.
540        let issues = store.list()?;
541        let board_config = config.default_config();
542        let keybindings_root =
543            Config::project_root(&store.issues_dir).unwrap_or_else(|| PathBuf::from("."));
544        let bindings = crate::keybindings::KeyBindings::load_effective(&keybindings_root)?;
545        let resolution = BoardState::resolution_for(&store, board_config);
546        // Sub-issues are nested beneath their parents by default (tix #1399); the
547        // `u` toggle flips `show_children` and rebuilds from the same corpus.
548        let board =
549            BoardState::build_from_issues(&issues, board_config, &store.config, &resolution, true);
550        let planner = Planner::from_config(config.clone(), std::path::PathBuf::from("."));
551        let rollover_plan = planner.rollover_plan_from(&issues, &today());
552        let corpus = Corpus::from_issues(issues);
553        // The snapshot is derived from the corpus the board is about to be built
554        // from, so a first frame shows the same figures a refresh would (WP05 T025).
555        let wip = crate::wip::WipSnapshot::build(config.default_config(), &corpus.issues);
556        let rollover_dialog = if rollover_plan.is_empty() {
557            None
558        } else {
559            Some(RolloverDialog::new(
560                rollover_plan
561                    .into_iter()
562                    .map(|p| (p.from_cycle, p.to_cycle, p.issues.len()))
563                    .collect(),
564            ))
565        };
566        let mut app = App {
567            mode: if rollover_dialog.is_some() {
568                Mode::RolloverDialog
569            } else {
570                Mode::Board
571            },
572            top_mode: TopMode::default(),
573            nav_display: NavDisplay::default(),
574            nav_focus: None,
575            milestone_mode: false,
576            board,
577            theme,
578            // The default board is the active set; archived issues are read only
579            // when the toggle asks for them (tix #1492).
580            show_archived: false,
581            show_children: true,
582            help_scroll: 0,
583            help_query: String::new(),
584            help_searching: false,
585            help_metrics: std::cell::Cell::new(HelpMetrics::default()),
586            board_page: std::cell::Cell::new(0),
587            detail: None,
588            form: None,
589            confirm_delete_target: None,
590            store,
591            // A session spawned from a parent repository says so once, so the
592            // user knows F6 goes back (tix #1529).
593            status_msg: started_from_parent
594                .then(|| "Started from a parent repository — F6 Repos to return".to_string()),
595            action_feedback: None,
596            quit: false,
597            bindings,
598            config_snapshot,
599            active_config: 0,
600            tabs,
601            filter: None,
602            corpus,
603            search_corpus: None,
604            filter_modal: None,
605            settings: None,
606            planning: None,
607            planning_form: None,
608            date_picker: None,
609            structured: search::StructuredFilter::default(),
610            rollover_dialog,
611            form_picker: None,
612            filter_picker: None,
613            one_shot: false,
614            frame_hit_regions: RefCell::new(Vec::new()),
615            mouse: MouseState::default(),
616            hover_action: None,
617            action_menu: None,
618            pending_move: None,
619            time_return: None,
620            bulk: None,
621            bulk_action_targets: None,
622            views: None,
623            view_session: None,
624            triage: None,
625            cycle_state: None,
626            repos_cursor: 0,
627            recurring_warning: None,
628            mentions: None,
629            mentions_view: None,
630            unread_mentions: 0,
631            mention_candidates: Vec::new(),
632            // The corpus is built just above, and the snapshot is derived from it:
633            // a board's first frame shows the same figures a refresh would.
634            wip,
635            wip_override: None,
636            // Read once at startup, so the first detail pane opened has health to
637            // show rather than an empty block until the next board rebuild.
638            health: crate::commands::export::run_health_core(config).ok(),
639            health_form: None,
640            stats_tab: StatsTab::default(),
641            stats_time: None,
642            stats_burndown: None,
643            time_form: None,
644            time_filter_form: None,
645            stats_cursor: 0,
646            pending_wip_override: None,
647            schedule: None,
648            cycle_schedule_warnings: None,
649            capacity: None,
650            library: None,
651            portfolio: None,
652            portfolio_projects: None,
653            portfolio_reports: None,
654            portfolio_epics: None,
655            portfolio_roadmap: None,
656            roadmap_filter: crate::roadmap::RoadmapFilter::default(),
657            portfolio_roadmap_overlay: None,
658            planning_return: Mode::Board,
659            drag_preview_col: None,
660            frame_size: Cell::new(Rect::new(0, 0, 0, 0)),
661            pending_edit: None,
662            nav_stack: Vec::new(),
663            related_picker: None,
664            tips: None,
665        };
666        // Detection on **start** (WP07 T033): the same read-only pass a refresh
667        // runs, so the first frame after launch already knows what is due.
668        app.refresh_recurring_warnings(false);
669        // The mention count, read once at startup so the indicator row is right on
670        // the first frame rather than after an `r`. Read-only.
671        app.refresh_mentions();
672        // Unread mentions put the all-issues modal up first, so what needs attention
673        // is seen before the board. Like the recurring warning, it only raises from
674        // the board — a rollover prompt or a due warning keeps its place.
675        if app.unread_mentions > 0 && matches!(app.mode, Mode::Board) {
676            app.open_mentions();
677        }
678        // The cycle-schedule twin of the pass above (WP07 T035): the same read-only
679        // evaluation a refresh runs, so the Cycle schedules tab's warnings are there
680        // on the first frame rather than after an `r`.
681        app.refresh_cycle_schedule_warnings();
682        // The capacity read (WP06): one pass over the shared pair, so the Capacity tab,
683        // the board's cycle group headers and every detail pane opened afterwards read
684        // the same reading rather than each building another.
685        app.refresh_capacity_view();
686        Ok(app)
687    }
688
689    /// The active `BoardConfiguration` indexed by [`App::active_config`].
690    fn active_board_config(&self) -> &BoardConfiguration {
691        self.store
692            .config
693            .configs()
694            .nth(self.active_config)
695            .map(|(_, board)| board)
696            .unwrap_or_else(|| self.store.config.default_config())
697    }
698
699    // The pointer handlers below are filled in by their mouse tasks
700    // (dialog click targets 5.x); until then they are honest stubs that
701    // return a no-op so the dispatch trunk stays live.
702
703    // -- mentions (OpenSpec change `issue-mentions`) -------------------------
704
705    fn project_root(&self) -> PathBuf {
706        Config::project_root(&self.store.issues_dir).unwrap_or_else(|| PathBuf::from("."))
707    }
708
709    fn reload_config_from(&mut self, root: &std::path::Path) -> Result<()> {
710        let cfg = Config::load_with_warning(&root.join("tix.toml"), |_| {})?;
711        self.bindings = crate::keybindings::KeyBindings::load_effective(root)?;
712        self.store.config = cfg;
713        Ok(())
714    }
715
716    /// Re-read `tix.toml` **when it changed on disk**, so a hand-edit made while
717    /// the session is open is honoured without a restart.
718    ///
719    /// Called before linting an editor round trip, because the lint's job is to
720    /// report the project's *current* rules: warning `unknown_key` for a key the
721    /// user has just declared in `[custom_attributes]` is a false positive, and a
722    /// false positive is what teaches someone to ignore the warning. It also
723    /// prevents the declare-fix from rewriting `tix.toml` from a stale in-memory
724    /// copy.
725    ///
726    /// A `tix.toml` that does not load is reported and the previous configuration
727    /// is kept, and the snapshot is **not** advanced — a half-saved file is
728    /// retried on the next call rather than latched.
729    fn reload_config_if_changed(&mut self) -> bool {
730        let Ok(path) = self.store.config_path() else {
731            return false;
732        };
733        let Ok(bytes) = std::fs::read(&path) else {
734            return false;
735        };
736        if self.config_snapshot.as_deref() == Some(bytes.as_slice()) {
737            return false;
738        }
739        let root = path
740            .parent()
741            .map(std::path::Path::to_path_buf)
742            .unwrap_or_else(|| std::path::PathBuf::from("."));
743        match self.reload_config_from(&root) {
744            Ok(()) => {
745                self.config_snapshot = Some(bytes);
746                self.reload_tabs();
747                // The capacity cache holds the index and issue snapshot built
748                // under the previous configuration. Re-read both here, before
749                // another render can pair the new ceiling with the old
750                // conversion table.
751                self.refresh_capacity_view();
752                self.refresh_cycle_schedule_warnings();
753                self.rebuild_board();
754                true
755            }
756            Err(e) => {
757                self.status_msg = Some(format!("tix.toml: {e} — kept the previous configuration"));
758                false
759            }
760        }
761    }
762
763    // -- saved views (WP05, T024–T027) ------------------------------------
764    //
765    // The whole section is an **adapter**, like `src/commands/views.rs` is for
766    // the CLI: it owns no rule of its own. Ids, scopes, plan validation, apply,
767    // CRUD, definition-only delete, the active marker, and divergence all come
768    // from WP01–WP03 (`crate::views`); the filter grammar comes from
769    // `crate::search`, and the board rebuild from the existing
770    // `apply_filter`/`rebuild_board`. What this section decides is only:
771    //
772    // - **where** a view came from (the live filter state), and
773    // - **what** the user sees (the indicator and the feedback panel).
774    //
775    // Three boundaries are load-bearing and are why the code looks the way it
776    // does:
777    //
778    // - **Applying writes nothing.** [`Self::apply_view`] calls
779    //   `ViewService::apply`, which borrows the config and the corpus, and then
780    //   installs the returned plan into the live filter state through the
781    //   existing `apply_filter`. No issue, comment, relationship, or `tix.toml`
782    //   byte changes (C-005).
783    // - **The marker is informational** (C-006). [`Self::live_plan`] derives the
784    //   live plan from the *current* filter state and the *current* visibility,
785    //   so editing the filter after applying a view makes the divergence report
786    //   name the difference instead of silently reconciling it.
787    // - **Only create/update/duplicate/delete write**, each through the one
788    //   audited registry commit, each validated before it (C-008).
789
790    // -----------------------------------------------------------------------
791    // WP07 (mission `repeating-cycle-schedules-01M3C7W1`): the cycle schedules on
792    // the F8 Schedule page's **Cycle schedules** tab.
793    // -----------------------------------------------------------------------
794
795    // -----------------------------------------------------------------------
796    // WP06 — the capacity read: the Capacity tab, the board's cells and the
797    // issue detail all render this one reading.
798    // -----------------------------------------------------------------------
799}
800
801mod render;
802pub use render::render;
803use render::{
804    HINT_HEIGHT, HOVER_HEIGHT, HealthRow, RENDER_BOTTOM_MARGIN, RENDER_FALLBACK_HEIGHT,
805    RENDER_FALLBACK_WIDTH, TAB_HEIGHT, capacity_cell, capacity_detail, capacity_fields,
806    capacity_row, feed_minutes, filter_band_height, issue_estimate_line, mention_row_height,
807    open_in_browser, plan_lines, run_child_tix, template_default_lines, template_subtitle,
808    theme_for,
809};
810
811mod cycle;
812mod filter;
813mod library;
814mod mouse;
815mod nav;
816mod pickers;
817mod planning;
818mod portfolio;
819mod roadmap;
820mod schedule;
821mod schedule_capacity;
822mod schedule_forms;
823mod settings;
824mod shell;
825mod stats;
826mod stats_burndown;
827mod stats_health;
828mod stats_time;
829#[cfg(test)]
830mod tests;
831mod views;
832mod wip;
833pub(in crate::app) use filter::FilterPicker;
834pub use pickers::FormPickerField;
835pub(in crate::app) use pickers::{FormPicker, LEGACY_TEMPLATE_PREFIX, STABLE_TEMPLATE_PREFIX};
836mod actions;
837mod board_input;
838mod confirm;
839mod detail_input;
840mod editor_input;
841mod forms;
842mod help;
843mod refresh;
844mod relationships;
845mod repair;
846mod session;
847#[cfg(test)]
848pub(in crate::app) use session::{
849    Startup, cells_to_text, render_once_at, render_to_cells, resolve_render_size,
850};
851pub use session::{drain_disclosures, render_once, run, run_new_issue};
852pub(in crate::app) use session::{error_message, io_err, names_backup};
853pub use stats::StatsTab;
854use stats_health::HealthForm;
855pub use stats_time::StatsTimeState;
856use stats_time::{TimeBreakdown, TimeFilterForm, TimeForm};