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};