tix/app/forms.rs
1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4impl App {
5 #[allow(clippy::too_many_lines)]
6 pub(in crate::app) fn handle_form_key(&mut self, key: KeyEvent) -> Result<Action> {
7 if self.form_picker.is_some() {
8 return self.handle_form_picker_key(key);
9 }
10 let form = match self.form.as_mut() {
11 Some(f) => f,
12 None => return Ok(Action::None),
13 };
14 match key.code {
15 KeyCode::Esc => {
16 self.form = None;
17 self.mode = Mode::Board;
18 Ok(Action::None)
19 }
20 KeyCode::Enter => {
21 // The Target row is a toggle, not a submit: Enter flips Triage /
22 // Todo and writes the matching status, which is what determines
23 // the created issue's column. It has to be handled before the
24 // submit path, or choosing a target would create the issue.
25 if form.focus.is_target() {
26 let next = form.target.next();
27 self.set_form_target(next);
28 return Ok(Action::None);
29 }
30 // Likewise the Template row: Enter opens the picker rather than
31 // submitting a form whose template the user was reaching for.
32 if form.focus.is_template() {
33 self.open_template_picker();
34 return Ok(Action::None);
35 }
36 if form.is_date_focus() && !form.focused_date_is_complete() {
37 self.open_date_picker(Mode::Form);
38 return Ok(Action::None);
39 }
40 // A parent is chosen, never typed, so an **empty** Parent row has
41 // nothing to submit and Enter means "choose". This is the same rule
42 // the date fields follow one branch above — Enter opens the picker
43 // only while the value is missing, and submits once it is there.
44 //
45 // Not "always open the picker", which was the first attempt: that
46 // makes Parent a field the form can never be submitted from, because
47 // Enter there never submits and the only way out is to tab away. A
48 // chosen field that traps the submit key is worse than a plain text
49 // field.
50 if form.focus.is_parent() && form.parent.trim().is_empty() {
51 self.open_form_picker();
52 return Ok(Action::None);
53 }
54 // Status and Priority are chosen from their dropdowns and refuse
55 // typing (tix #1508), so Enter on either row means "choose" and
56 // never submits — the same way a Parent row keeps Enter for the
57 // picker. The user submits from another row.
58 if form.focus.is_status() || form.focus.is_priority() {
59 self.open_form_picker();
60 return Ok(Action::None);
61 }
62 // A stable-ID template selection resolves through the **shared**
63 // resolver and writes through the **shared** create core
64 // (C-007/NFR-002). Only the pick/toggle interactions above are
65 // handled first — this is the submit itself, so it runs before the
66 // form's own field checks: the resolver owns required/reference
67 // diagnostics and the create core owns column/planning/date
68 // validation, so neither is restated here (and the two paths
69 // cannot drift).
70 if let Some(scope) = form.template_scope.clone() {
71 let id = form.template.clone();
72 let input = template_input_from_form(form);
73 // WP06 T029: the template route carries the Projects row too, so
74 // the row is not a no-op on the one create path that goes through
75 // the shared resolver. Resolved through the service, before
76 // anything is written, exactly as the other route does — a refusal
77 // leaves the form and the store untouched.
78 let project_ids = match crate::commands::project::resolve_projects(
79 &self.store.config,
80 &form.projects,
81 ) {
82 Ok(ids) => ids,
83 Err(e) => {
84 self.status_msg = Some(e.to_string());
85 return Ok(Action::None);
86 }
87 };
88 // WP06 T031: the template route carries the Epics row too,
89 // resolved through the epic service before anything is written —
90 // a refusal leaves the form and the store untouched, exactly as
91 // the other route does.
92 let epic_ids =
93 match crate::commands::epic::resolve_epics(&self.store.config, &form.epics)
94 {
95 Ok(ids) => ids,
96 Err(e) => {
97 self.status_msg = Some(e.to_string());
98 return Ok(Action::None);
99 }
100 };
101 return self.submit_stable_template(
102 &id,
103 &scope,
104 &input,
105 &project_ids,
106 &epic_ids,
107 );
108 }
109 if form.title.trim().is_empty() {
110 self.status_msg = Some("Title is required".to_string());
111 return Ok(Action::None);
112 }
113 let start_date = if form.start_date.trim().is_empty() {
114 None
115 } else {
116 Some(form.start_date.trim().to_string())
117 };
118 let due_date = if form.due_date.trim().is_empty() {
119 None
120 } else {
121 Some(form.due_date.trim().to_string())
122 };
123 if let Some(start) = start_date.as_deref()
124 && !crate::model::validate_date(start)
125 {
126 self.status_msg = Some("Start date must be YYYY-MM-DD".to_string());
127 return Ok(Action::None);
128 }
129 if let Some(due) = due_date.as_deref()
130 && !crate::model::validate_date(due)
131 {
132 self.status_msg = Some("Due date must be YYYY-MM-DD".to_string());
133 return Ok(Action::None);
134 }
135 if let (Some(start), Some(due)) = (start_date.as_deref(), due_date.as_deref())
136 && due < start
137 {
138 self.status_msg = Some("Due date must be >= start date".to_string());
139 return Ok(Action::None);
140 }
141 let title = form.title.trim().to_string();
142 // The body is submitted verbatim — not trimmed, because leading or
143 // trailing blank lines in Markdown are content, and a template
144 // writes them on purpose.
145 let body = form.body.clone();
146 let status_str = if form.status.trim().is_empty() {
147 self.store
148 .config
149 .default_config()
150 .columns
151 .first()
152 .cloned()
153 .unwrap_or_else(|| "todo".to_string())
154 } else {
155 form.status.trim().to_string()
156 };
157 let priority: Priority = form.priority.parse().unwrap_or_default();
158 let identity = git_identity();
159 let assignee = if form.assignee.trim().is_empty() {
160 None
161 } else {
162 resolve_me(form.assignee.trim(), identity.as_ref())
163 };
164 let labels: Vec<String> = form
165 .labels
166 .split(',')
167 .map(|s| s.trim().to_string())
168 .filter(|s| !s.is_empty())
169 .collect();
170 let estimate = if form.estimate.trim().is_empty() {
171 None
172 } else {
173 Some(form.estimate.trim().to_string())
174 };
175 let cycles = {
176 let values: Vec<String> = form
177 .cycles
178 .split(',')
179 .map(str::trim)
180 .filter(|s| !s.is_empty())
181 .map(str::to_string)
182 .collect();
183 if values.is_empty() {
184 // No explicit selection joins the default cycle, the
185 // same answer the CLI create core gives (tui-cycle-mode).
186 Some(vec![crate::commands::create::default_cycle_name(
187 &self.store.config,
188 )])
189 } else {
190 Some(values)
191 }
192 };
193 let milestones = {
194 let values: Vec<String> = form
195 .milestones
196 .split(',')
197 .map(str::trim)
198 .filter(|s| !s.is_empty())
199 .map(str::to_string)
200 .collect();
201 (!values.is_empty()).then_some(values)
202 };
203 let reviewer = if form.reviewer.trim().is_empty() {
204 None
205 } else {
206 Some(form.reviewer.trim().to_string())
207 };
208
209 // The parent is resolved and validated through the hierarchy
210 // service **before** anything is written, exactly as
211 // `commands::create` does it — same resolution, same
212 // `validate_new_parent`, so a bad parent creates no file, no
213 // number, and no History entry. The service's own diagnostic is
214 // what reaches `status_msg`, so the TUI says what the CLI says
215 // rather than a second wording of the same rule.
216 //
217 // `validate_new_parent`, not `set_parent`: the child does not exist
218 // yet, so it cannot be its own parent and cannot close a cycle. A
219 // self-parent and a cycle are both *unrepresentable* here, not merely
220 // rejected.
221 //
222 // **This call is not test-covered, and the reason is worth stating
223 // rather than leaving implied.** The picker's options come from the
224 // store, so every value the user can choose is a live issue; and
225 // `unknown_parent` needs a selector that *resolves* to a number the
226 // index does not hold. `resolve_issue` and `hierarchy_index` each
227 // re-read the store, so that state exists only for a delete landing
228 // between the two reads — a window no single-threaded test can enter,
229 // since deleting first makes `resolve_issue` fail instead (which is
230 // the refusal `t027_a_rejected_parent_keeps_the_form_open...` does
231 // assert). Reverting the line leaves the suite green, so this is
232 // defence in depth against a concurrent delete, kept because the CLI
233 // has the identical line and the charter's parity boundary is about
234 // what both surfaces *do*, not about which of them is covered.
235 //
236 // A rejection returns to the form with the field untouched, so the
237 // user keeps everything they typed and can pick again.
238 let (parent_ref, parent_number) = match form.parent.trim() {
239 "" => (None, None),
240 raw => match self.store.resolve_issue(raw).and_then(|target| {
241 let (index, _) = self.store.hierarchy_index()?;
242 crate::hierarchy::validate_new_parent(&index, target.number)?;
243 Ok(target)
244 }) {
245 Ok(target) => {
246 (Some(crate::id::canonical_ref(&target)), Some(target.number))
247 }
248 Err(e) => {
249 self.status_msg = Some(e.to_string());
250 return Ok(Action::None);
251 }
252 },
253 };
254
255 // WP06 T029 step 4/5: the associations are **the service's**. They
256 // are resolved before anything is written, through the same
257 // `create --projects` helper the CLI uses, so an unknown project
258 // refuses the whole create with the service's own diagnostic — the
259 // form stays open, the field keeps its value, and no file, number or
260 // History entry is made. The form constructs no project and validates
261 // none; it hands the service the ids the picker chose.
262 let project_ids = match crate::commands::project::resolve_projects(
263 &self.store.config,
264 &form.projects,
265 ) {
266 Ok(ids) => ids,
267 Err(e) => {
268 self.status_msg = Some(e.to_string());
269 return Ok(Action::None);
270 }
271 };
272 // WP06 T031: the epic references are resolved through the service
273 // **before** anything is written, so an unknown epic refuses the whole
274 // create with the service's own diagnostic — the form stays open, the
275 // field keeps its value, and no file, number or History entry is made.
276 // The form constructs no epic and validates none; it hands the service
277 // the ids the picker chose.
278 let epic_ids =
279 match crate::commands::epic::resolve_epics(&self.store.config, &form.epics) {
280 Ok(ids) => ids,
281 Err(e) => {
282 self.status_msg = Some(e.to_string());
283 return Ok(Action::None);
284 }
285 };
286
287 let mut issue = Issue {
288 projects: None,
289 id: None,
290 number: 0,
291 type_: None,
292 title,
293 status: Status::new(status_str),
294 labels,
295 priority,
296 assignee,
297 estimate,
298 commit: None,
299 gitlab_id: None,
300 linear_id: None,
301 mission: None,
302 task: None,
303 relationships: vec![],
304 related_issues: vec![],
305 series: None,
306 health: None,
307 files: vec![],
308 cycles,
309 milestones,
310 reviewer,
311 start_date,
312 due_date,
313 created: today(),
314 updated: today(),
315 filename: String::new(),
316 body: String::new(),
317 history: crate::model::History::new(),
318 has_retired_links_key: false,
319 time_entries: vec![],
320 parent: parent_ref,
321 epics: None,
322 extra_frontmatter: std::collections::BTreeMap::new(),
323 archived: false,
324 entered_at: None,
325 source: None,
326 };
327 // Exactly one History entry, naming the parent (C-004). Appended to
328 // the in-memory issue so `store.create` writes it with the first
329 // content — an entry that only appeared on a later edit would
330 // describe a create that did not happen this way. This is the step
331 // the TUI's hand-built `Issue` was silently skipping (tix #1385).
332 if let Some(parent_number) = parent_number {
333 issue.history.append_now(
334 &crate::storage::actor_identity(),
335 crate::model::HistoryEventKind::ParentSet,
336 &crate::hierarchy::change_detail(None::<u32>, Some(parent_number)),
337 );
338 }
339 // The entry stamp, through the **same** rule the CLI's create core
340 // uses: without it this form was the one create path that entered the
341 // inbox without recording when, so the queue reported an `unknown` age
342 // for an issue the TUI had just created. (The form still builds its own
343 // `Issue` rather than calling `commands::create::create` — see tix #1385
344 // for the parent/History line that cost once already; what must not be
345 // duplicated is the *rule*, and it no longer is.) Applied **before** the
346 // write, so the file and the stamp cannot disagree.
347 crate::triage::record_entry_if_inbox(&self.store.config, &mut issue, "new")?;
348 self.store.create(&mut issue, &body, None)?;
349 // WP06 T029 step 4: one store-level association write per id, the
350 // same helper the CLI's create core calls — so the History entry is
351 // appended **exactly once** per project and the idempotence rule is
352 // the service's, not a second implementation here. The ids were
353 // resolved before the issue existed, so this cannot leave a
354 // half-associated create.
355 crate::commands::project::associate_created(&self.store, &issue, &project_ids)?;
356 // WP06 T031 step 4: the epic references are written through the
357 // **service's** own store helper, so the History entry is appended
358 // exactly once per epic and the idempotence rule stays the service's,
359 // not a second implementation here. The ids were resolved before the
360 // issue existed, so this cannot leave a half-referenced create.
361 crate::commands::epic::reference_created(&self.store, &issue, &epic_ids)?;
362 // `issue.filename` is the store-relative path (including the
363 // assignee segment); the board's cards key on that, so select
364 // with it — the basename would never match and the wrong card
365 // would stay selected.
366 let filename = issue.filename.clone();
367 let basename = filename.rsplit('/').next().unwrap_or(&filename).to_string();
368 self.status_msg = Some(format!("Created {basename}"));
369 self.form = None;
370 self.mode = Mode::Board;
371 self.refresh_corpus()?;
372 self.select_card_by_filename(&filename);
373 Ok(Action::None)
374 }
375 KeyCode::Tab | KeyCode::Down => {
376 form.move_focus_next();
377 Ok(Action::None)
378 }
379 KeyCode::BackTab | KeyCode::Up => {
380 form.move_focus_prev();
381 Ok(Action::None)
382 }
383 // On the Target row, Left/Right pick a side directly (Enter above
384 // toggles). Everywhere else they move the insertion point one
385 // character, or one word with Ctrl/Alt held (tix #1746).
386 KeyCode::Left => {
387 if form.focus.is_target() {
388 self.set_form_target(crate::ui::form::NewIssueTarget::Triage);
389 } else if key
390 .modifiers
391 .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
392 {
393 form.move_cursor_word_left();
394 } else {
395 form.move_cursor_left();
396 }
397 Ok(Action::None)
398 }
399 KeyCode::Right => {
400 if form.focus.is_target() {
401 self.set_form_target(crate::ui::form::NewIssueTarget::Todo);
402 } else if key
403 .modifiers
404 .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
405 {
406 form.move_cursor_word_right();
407 } else {
408 form.move_cursor_right();
409 }
410 Ok(Action::None)
411 }
412 KeyCode::Home => {
413 form.cursor_to_start();
414 Ok(Action::None)
415 }
416 KeyCode::End => {
417 form.cursor_to_end();
418 Ok(Action::None)
419 }
420 KeyCode::F(2) => {
421 if form.is_date_focus() {
422 self.open_date_picker(Mode::Form);
423 } else {
424 self.open_form_picker();
425 }
426 Ok(Action::None)
427 }
428 // The Emacs set (`Ctrl-A/E/B/F/H/D/K/U/W`) and `Ctrl-Backspace`,
429 // which is what readline-style prompts everywhere bind. Keys are
430 // matched on the **character**, case-folded, because a terminal may
431 // report Ctrl-Shift-A as either case.
432 KeyCode::Char(c) if key.modifiers.contains(KeyModifiers::CONTROL) => {
433 match c.to_ascii_lowercase() {
434 'a' => form.cursor_to_start(),
435 'e' => form.cursor_to_end(),
436 'b' => form.move_cursor_left(),
437 'f' => form.move_cursor_right(),
438 'h' => form.delete_char(),
439 'd' => form.delete_forward_char(),
440 'k' => form.delete_to_end(),
441 'u' => form.delete_to_start(),
442 'w' => form.delete_word_backward(),
443 _ => {}
444 }
445 Ok(Action::None)
446 }
447 KeyCode::Char(c) => {
448 form.insert_char(c);
449 Ok(Action::None)
450 }
451 KeyCode::Backspace => {
452 if key
453 .modifiers
454 .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
455 {
456 form.delete_word_backward();
457 } else {
458 form.delete_char();
459 }
460 Ok(Action::None)
461 }
462 KeyCode::Delete => {
463 form.delete_forward_char();
464 Ok(Action::None)
465 }
466 _ => Ok(Action::None),
467 }
468 }
469
470 /// Submit the form through the shared template resolver and create core
471 /// (WP05/T025).
472 ///
473 /// The selection is resolved by [`crate::templates::resolve`], which is pure
474 /// and returns **every** diagnostic and no request on failure; a failed
475 /// resolution sets `status_msg` and leaves both the form and the store
476 /// untouched — no partial issue, exactly as the CLI's template path
477 /// (C-005/FR-004). On success the request is destructured straight into
478 /// [`crate::commands::create::create`], the same core `tix create --template`
479 /// and the MCP create tool use, so the created issue is equivalent to the
480 /// CLI's for the same template, scope and input (C-007/NFR-002). The template
481 /// ID and scope stay visible: they are on the form's Template row before
482 /// submit and in the result message after (FR-005).
483 pub(in crate::app) fn submit_stable_template(
484 &mut self,
485 id: &str,
486 scope: &str,
487 input: &TemplateInput,
488 project_ids: &[String],
489 epic_ids: &[String],
490 ) -> Result<Action> {
491 let scope = match scope.parse::<TemplateScope>() {
492 Ok(scope) => scope,
493 Err(error) => {
494 self.status_msg = Some(error);
495 return Ok(Action::None);
496 }
497 };
498 let scope_label = scope.to_string();
499 let selection = TemplateSelection::new(id, scope);
500 let resolution = resolve(&self.store.config, &selection, input);
501 if !resolution.is_ok() {
502 let diagnostics = resolution
503 .diagnostics()
504 .iter()
505 .map(|diagnostic| format!("{}: {}", diagnostic.field(), diagnostic.message()))
506 .collect::<Vec<_>>()
507 .join(" | ");
508 self.status_msg = Some(format!(
509 "template `{}` at scope `{}` did not resolve: {diagnostics}",
510 resolution.template_id(),
511 resolution.scope()
512 ));
513 return Ok(Action::None);
514 }
515 let request = resolution
516 .into_request()
517 .expect("a successful resolution always carries a request");
518 let issue = match crate::commands::create::create(
519 &self.store.config,
520 &self.store,
521 &request.title,
522 request.body.as_deref(),
523 request.status.clone(),
524 request.priority.clone(),
525 request.assignee.as_deref(),
526 request.labels.as_deref(),
527 request.files.as_deref(),
528 request.estimate.as_deref(),
529 request.cycles.as_deref(),
530 request.milestones.as_deref(),
531 request.reviewer.as_deref(),
532 request.start_date.as_deref(),
533 request.due_date.as_deref(),
534 request.parent.as_deref(),
535 request.related_issues.as_deref(),
536 request.type_.as_deref(),
537 ) {
538 Ok(issue) => issue,
539 Err(error) => {
540 // The create core validates the column, planning references,
541 // dates and parent **before** `Store::create`, so a refusal here
542 // wrote nothing; the form keeps everything the user typed.
543 self.status_msg = Some(error.to_string());
544 return Ok(Action::None);
545 }
546 };
547 let filename = issue.filename.clone();
548 // WP06 T029 step 4: the same store-level association write the other create
549 // route uses, so the Project History entry is appended exactly once and the
550 // idempotence rule stays the service's. The ids were resolved before the
551 // create, so this cannot leave a half-associated issue.
552 crate::commands::project::associate_created(&self.store, &issue, project_ids)?;
553 // WP06 T031: the epic references, through the same store-level service path
554 // the other create route uses, so the History entry is appended exactly once
555 // per epic and the idempotence rule stays the service's.
556 crate::commands::epic::reference_created(&self.store, &issue, epic_ids)?;
557 let basename = filename.rsplit('/').next().unwrap_or(&filename).to_string();
558 self.status_msg = Some(format!(
559 "Created {basename} (template `{id}` at scope `{scope_label}`)"
560 ));
561 self.form = None;
562 self.mode = Mode::Board;
563 self.refresh_corpus()?;
564 self.select_card_by_filename(&filename);
565 Ok(Action::None)
566 }
567
568 /// Open the calendar picker, seeded from the focused date field of the
569 /// modal identified by `return_to`.
570 pub(in crate::app) fn open_date_picker(&mut self, return_to: Mode) {
571 let current = match return_to {
572 Mode::Form => self.form.as_ref().map(|f| f.focused_value().to_string()),
573 Mode::PlanningForm => self
574 .planning_form
575 .as_ref()
576 .map(|f| f.focused_value().to_string()),
577 Mode::FilterModal => self.filter_modal.as_ref().map(|m| m.focused_value()),
578 _ => None,
579 };
580 self.date_picker = Some(DatePicker::new(current.as_deref().unwrap_or("")));
581 self.mode = Mode::DatePicker {
582 return_to: Box::new(return_to),
583 };
584 }
585
586 pub(in crate::app) fn handle_date_picker_key(&mut self, key: KeyEvent) -> Result<Action> {
587 let Some(picker) = self.date_picker.as_mut() else {
588 self.mode = Mode::Board;
589 return Ok(Action::None);
590 };
591 match picker.handle_key(key.code) {
592 DatePickerEvent::None => {}
593 DatePickerEvent::Cancelled => self.close_date_picker(),
594 DatePickerEvent::Selected(value) => {
595 self.apply_date_selection(&value);
596 self.close_date_picker();
597 }
598 DatePickerEvent::Cleared => {
599 self.apply_date_selection("");
600 self.close_date_picker();
601 }
602 }
603 Ok(Action::None)
604 }
605
606 /// Close the picker and restore the modal that opened it.
607 pub(in crate::app) fn close_date_picker(&mut self) {
608 let return_to = match std::mem::replace(&mut self.mode, Mode::Board) {
609 Mode::DatePicker { return_to } => *return_to,
610 _ => Mode::Board,
611 };
612 self.date_picker = None;
613 self.mode = return_to;
614 }
615
616 /// Write the picked `value` (empty clears) into the modal the picker was
617 /// opened from.
618 pub(in crate::app) fn apply_date_selection(&mut self, value: &str) {
619 let return_to = match &self.mode {
620 Mode::DatePicker { return_to } => (**return_to).clone(),
621 _ => return,
622 };
623 match return_to {
624 Mode::Form => {
625 if let Some(form) = self.form.as_mut() {
626 form.set_focused_date(value);
627 }
628 }
629 Mode::PlanningForm => {
630 if let Some(form) = self.planning_form.as_mut() {
631 form.set_focused_date(value);
632 }
633 }
634 Mode::FilterModal => {
635 if let Some(modal) = self.filter_modal.as_mut() {
636 modal.set_focused_date(value);
637 }
638 self.rebuild_board();
639 }
640 _ => {}
641 }
642 }
643
644 /// The status a New Issue target lands the created issue in.
645 ///
646 /// Derived from configuration rather than hard-coded so a renamed column still
647 /// works: `Triage` is the inbox's `entry_status` when the project has one, and
648 /// `Todo` is a literal `todo` column when the board names one. The `Todo`
649 /// fallbacks keep a project whose columns are not named `todo` creating
650 /// *somewhere real* — its accepted status, then its first column — rather than
651 /// writing a status the board cannot show.
652 pub(in crate::app) fn new_issue_target_status(&self, target: NewIssueTarget) -> String {
653 let triage = self.store.config.triage.as_ref();
654 match target {
655 NewIssueTarget::Triage => triage
656 .and_then(|t| t.entry_status.as_deref())
657 .map(str::trim)
658 .filter(|s| !s.is_empty())
659 .unwrap_or("triage")
660 .to_string(),
661 NewIssueTarget::Todo => {
662 let columns = &self.store.config.default_config().columns;
663 if columns.iter().any(|c| c == "todo") {
664 return "todo".to_string();
665 }
666 triage
667 .and_then(|t| t.accepted_status.as_deref())
668 .map(str::trim)
669 .filter(|s| !s.is_empty())
670 .map(str::to_string)
671 .or_else(|| columns.first().cloned())
672 .unwrap_or_else(|| "todo".to_string())
673 }
674 }
675 }
676
677 /// Choose a New Issue target and write its status into the Status row.
678 ///
679 /// Writing the status is what makes the choice *determine* the created issue's
680 /// column: submit reads `form.status`, so the Target row cannot disagree with
681 /// what is created. The Status row stays editable afterwards, which is how a
682 /// column the two targets do not name is still reachable.
683 pub(in crate::app) fn set_form_target(&mut self, target: NewIssueTarget) {
684 let status = self.new_issue_target_status(target);
685 if let Some(form) = self.form.as_mut() {
686 form.target = target;
687 form.status = status;
688 }
689 }
690
691 /// Open the Template row's picker over the configured templates.
692 ///
693 /// A `(none)` option comes first so the selection is clearable; choosing it
694 /// only drops the label, leaving whatever the template pre-filled in place,
695 /// because the user's typed values are the form's, not the template's.
696 ///
697 /// **Applicability boundary (WP05/T026):** template *management* —
698 /// create/update/duplicate/delete and the preserved-unknown-metadata view —
699 /// lives on the CLI (`tix template …`) and is deliberately not duplicated
700 /// here. The TUI's surface is *selection*: it lists each stable template's
701 /// ID, display name and explicit scope and seeds the form from the chosen
702 /// one, which is the metadata this mode actually needs. Neither the board nor
703 /// the form mode is otherwise touched.
704 pub(in crate::app) fn open_template_picker(&mut self) {
705 if self.store.config.issue_templates.is_empty() && self.store.config.templates.is_empty() {
706 self.status_msg = Some(
707 "No templates configured; add [issue_templates.<id>] or [templates.<name>] to \
708 tix.toml"
709 .to_string(),
710 );
711 return;
712 }
713 let mut options = vec![PickerItem {
714 id: String::new(),
715 label: "(none)".to_string(),
716 }];
717 options.extend(self.template_picker_options());
718 let mut state = DropdownState::single(options);
719 self.seed_picker_selection(FormPickerField::Template, &mut state);
720 self.form_picker = Some(FormPicker {
721 field: FormPickerField::Template,
722 state,
723 });
724 }
725
726 /// Pre-fill the form from a named **legacy** template, or clear the selection
727 /// for an empty/unknown name.
728 ///
729 /// Clears any stable scope, so the submit path cannot keep resolving through
730 /// the stable resolver after a legacy record is chosen.
731 pub(in crate::app) fn select_template(&mut self, name: &str) {
732 let template = self.store.config.templates.get(name).cloned();
733 if let Some(form) = self.form.as_mut() {
734 match template {
735 Some(template) => form.apply_template(name, &template),
736 None => {
737 form.template.clear();
738 form.template_scope = None;
739 }
740 }
741 }
742 }
743
744 /// Pre-fill the form from a stable-ID template and record its ID + scope, or
745 /// clear the selection when the ID is unknown.
746 ///
747 /// The scope is the template's **own declared scope**, which is explicit and
748 /// visible (C-003/FR-005); the shared resolver re-checks it at submit, so a
749 /// mismatch can never reach a write.
750 pub(in crate::app) fn select_stable_template(&mut self, id: &str) {
751 let template = self.store.config.issue_templates.get(id).cloned();
752 if let Some(form) = self.form.as_mut() {
753 match template {
754 Some(template) => {
755 let scope = template.scope.to_string();
756 form.apply_stable_template(id, &scope, &template);
757 }
758 None => {
759 form.template.clear();
760 form.template_scope = None;
761 }
762 }
763 }
764 }
765
766 /// Decode a namespaced Template picker option id and apply it.
767 ///
768 /// An empty id is `(none)` and clears the selection without emptying the
769 /// form. An unprefixed non-empty id is treated as a legacy name, so a raw
770 /// name still selects the legacy record rather than silently clearing the row.
771 pub(in crate::app) fn apply_template_choice(&mut self, raw_id: &str) {
772 if let Some(id) = raw_id.strip_prefix(STABLE_TEMPLATE_PREFIX) {
773 self.select_stable_template(id);
774 } else if let Some(name) = raw_id.strip_prefix(LEGACY_TEMPLATE_PREFIX) {
775 self.select_template(name);
776 } else if raw_id.is_empty() {
777 if let Some(form) = self.form.as_mut() {
778 form.template.clear();
779 form.template_scope = None;
780 }
781 } else {
782 self.select_template(raw_id);
783 }
784 }
785
786 /// Run a New Issue-mode pointer action recorded during the draw. Public so the
787 /// hit-region tests drive the same path the run loop does.
788 pub fn apply_new_issue_action(&mut self, action: Action) -> Result<Action> {
789 match action {
790 Action::TemplateOpen => self.open_template_picker(),
791 Action::TemplatePickOption(row) => {
792 let chosen = self.form_picker.as_ref().and_then(|picker| {
793 if picker.field != FormPickerField::Template {
794 return None;
795 }
796 let index = *picker.state.visible_indices().get(row)?;
797 Some(picker.state.options.get(index)?.id.clone())
798 });
799 if let Some(id) = chosen {
800 self.form_picker = None;
801 self.apply_template_choice(&id);
802 }
803 }
804 _ => {}
805 }
806 Ok(Action::None)
807 }
808
809 /// Open the F4 New Issue mode — `n` on the board, the F4 shell chord, and
810 /// where `tix new` starts the session (#1398, tui-new-issue-mode).
811 ///
812 /// Seeds the default target's status and the git identity (FR-028, best effort:
813 /// empty when git is absent, never an error). One method rather than copies,
814 /// because the key, the shell switch, and the command must produce the *same*
815 /// form — entry points into one mode that default differently is how a bug is
816 /// born.
817 pub fn open_new_issue_form(&mut self) {
818 let default_assignee = crate::assignee::git_identity()
819 .map(|id| id.to_string())
820 .unwrap_or_default();
821 let target = NewIssueTarget::default();
822 let default_status = self.new_issue_target_status(target);
823 let mut form = FormState::with_default_assignee(&default_status, &default_assignee);
824 form.target = target;
825 self.enter_form(form);
826 }
827
828 /// Put `form` on screen as the New Issue mode and record the shell mode.
829 pub(in crate::app) fn enter_form(&mut self, form: FormState) {
830 self.form = Some(form);
831 self.mode = Mode::Form;
832 // `sync_top_mode` would derive this on the next key, but the direct entry
833 // points (`tix new`, the F4 switch) draw the navigation highlight before any
834 // key arrives, so the mode must be recorded here rather than a frame later.
835 self.top_mode = TopMode::NewIssue;
836 }
837}