1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
#[allow(clippy::wildcard_imports)]
use super::*;
#[derive(Debug, Clone, PartialEq, Eq)]
/// The health form's state (mission `project-issue-health-status-01M3C8HX`, WP06).
///
/// The audit fields are the same three the CLI requires — state, actor, rationale —
/// plus the layer, so one form records both a `current` value and an `override`
/// instead of teaching two key paths that differ in one field.
///
/// Nothing here writes. On submit the form builds the same
/// [`crate::health::HealthUpdate`] the CLI builds, through
/// [`crate::commands::health::record`], so a value a user records from the TUI is
/// indistinguishable from one recorded from the command line (C-007).
pub struct HealthForm {
/// The issue the value is about.
pub issue: u32,
/// The state being recorded, typed.
pub state: String,
/// Who is making the change.
pub actor: String,
/// Why — required, and refused when blank.
pub rationale: String,
/// Which field the cursor is in: 0 state, 1 actor, 2 rationale, 3 layer.
pub field: usize,
/// The layer: 0 `current`, 1 `override`.
pub layer: u8,
}
impl HealthForm {
/// A form for `issue`, with the actor defaulted to the configured identity.
pub fn new(issue: u32) -> Self {
Self {
issue,
state: String::new(),
actor: crate::storage::actor_identity(),
rationale: String::new(),
field: 0,
layer: 0,
}
}
/// The layer's name, as the shared vocabulary spells it.
pub fn layer_name(&self) -> &'static str {
if self.layer == 0 {
"current"
} else {
"override"
}
}
/// The field the cursor is in, by name.
pub fn field_name(&self) -> &'static str {
match self.field {
0 => "state",
1 => "actor",
2 => "rationale",
_ => "layer",
}
}
}
impl App {
/// The Health tab's keys (WP06): walk the scope rows, open a row's issue.
pub(in crate::app) fn handle_health_tab_key(&mut self, key: KeyEvent) -> Result<Action> {
let rows = self.stats_rows();
if rows.is_empty() {
self.stats_cursor = 0;
} else {
self.stats_cursor = self.stats_cursor.min(rows.len() - 1);
}
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
self.stats_cursor = self.stats_cursor.saturating_sub(1);
}
KeyCode::Down | KeyCode::Char('j') => {
if !rows.is_empty() && self.stats_cursor + 1 < rows.len() {
self.stats_cursor += 1;
}
}
KeyCode::Enter => {
if let Some(row) = rows.get(self.stats_cursor) {
if let crate::health::HealthScope::Issue(number) = row.scope {
// A derived scope has no pane to open; the row is the answer.
match self
.store
.get(crate::storage::IssueSelector::Number(number))
{
Ok(issue) => self.open_detail_root(issue)?,
Err(error) => self.status_msg = Some(error.to_string()),
}
} else {
self.status_msg = Some(format!(
"{} is a derived scope: `tix health rollup --scope {} --id {}` \
shows its contributors",
row.scope.as_str(),
row.scope.as_str().split(':').next().unwrap_or("issue"),
row.scope.as_str().split_once(':').map_or("", |(_, id)| id),
));
}
}
}
_ => {}
}
Ok(Action::None)
}
/// Open the health form for the issue in the detail pane (WP06 T032).
pub(in crate::app) fn open_health_form(&mut self) -> Result<Action> {
let Some(detail) = self.detail.as_ref() else {
self.status_msg = Some("open an issue's detail first: the form edits one issue".into());
return Ok(Action::None);
};
if detail.read_only {
self.status_msg = Some(
"this issue is outside the store, so it has no file to record health in".into(),
);
return Ok(Action::None);
}
self.health_form = Some(HealthForm::new(detail.issue.number));
self.mode = Mode::HealthForm;
Ok(Action::None)
}
/// The form's keys: `Tab` walks the fields, `Enter` submits, `Esc` leaves.
pub(in crate::app) fn handle_health_form_key(&mut self, key: KeyEvent) -> Result<Action> {
if !matches!(self.mode, Mode::HealthForm) {
return Ok(Action::None);
}
match key.code {
KeyCode::Esc => {
self.health_form = None;
self.mode = Mode::Detail;
}
KeyCode::Tab => {
if let Some(form) = self.health_form.as_mut() {
form.field = (form.field + 1) % 4;
}
}
KeyCode::BackTab => {
if let Some(form) = self.health_form.as_mut() {
form.field = (form.field + 3) % 4;
}
}
// The layer is chosen, not typed: `space` toggles it **when the layer
// field is focused**. In every other field a space is a space — swallowing
// it there made "blocked by upstream" record as "blockedbyupstream".
KeyCode::Char(' ') => {
if let Some(form) = self.health_form.as_mut() {
if form.field == 3 {
form.layer = 1 - form.layer;
} else {
match form.field {
0 => form.state.push(' '),
1 => form.actor.push(' '),
_ => form.rationale.push(' '),
}
}
}
}
KeyCode::Backspace => {
if let Some(form) = self.health_form.as_mut() {
match form.field {
0 => {
form.state.pop();
}
1 => {
form.actor.pop();
}
2 => {
form.rationale.pop();
}
_ => {}
}
}
}
KeyCode::Enter => return self.confirm_health_form(),
KeyCode::Char(c) => {
if let Some(form) = self.health_form.as_mut() {
match form.field {
0 => form.state.push(c),
1 => form.actor.push(c),
2 => form.rationale.push(c),
_ => {}
}
}
}
_ => {}
}
Ok(Action::None)
}
/// Submit the form through the shared service, and report what happened.
///
/// The refusal path is the important one: a blank rationale, an unknown state, or
/// a scope with no record is refused **having written nothing**, and the reason is
/// shown in the status line rather than swallowed.
pub(in crate::app) fn confirm_health_form(&mut self) -> Result<Action> {
let Some(form) = self.health_form.clone() else {
self.mode = Mode::Detail;
return Ok(Action::None);
};
let source = if form.layer == 0 {
crate::health::HealthSource::Current
} else {
crate::health::HealthSource::Override
};
// The actor defaults to the configured identity, so the common case types one
// field fewer; the rationale does not, because a blank one is not a reason.
let actor = if form.actor.trim().is_empty() {
crate::storage::actor_identity()
} else {
form.actor.clone()
};
let result = crate::commands::health::record(
&self.store,
crate::health::HealthScope::of_issue(form.issue),
form.state.trim(),
actor.trim(),
&crate::model::today(),
form.rationale.trim(),
source,
);
match result {
Ok(applied) => {
self.health_form = None;
self.mode = Mode::Detail;
self.refresh_health();
self.refresh_detail()?;
self.status_msg = Some(format!(
"health: issue #{} {} ({})",
applied.issue.number,
applied
.issue
.health
.as_ref()
.and_then(|health| health.state.clone())
.unwrap_or_default(),
source.as_str()
));
}
Err(error) => {
// Stay in the form: the fields are the user's work, and the refusal
// names what to fix.
self.status_msg = Some(error.to_string());
}
}
Ok(Action::None)
}
}