Skip to main content

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}