tix/app/pickers.rs
1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum FormPickerField {
6 Labels,
7 Assignee,
8 Cycles,
9 Milestones,
10 Reviewer,
11 /// The issue's board column (tix #1508). Single-select, options are the
12 /// active board's columns plus the field's current value. No inline create:
13 /// a column the board does not configure renders in the Mismatched column, and
14 /// inventing one from a form is not something the dropdown should do.
15 Status,
16 /// The issue's priority (tix #1508). Single-select, options are the built-in
17 /// `low`/`medium`/`high` plus every priority the store already uses, plus the
18 /// current value. Inline `+` create is kept: a custom priority is a preserved
19 /// concept in the model (`Priority::Other`), so a genuinely new one must
20 /// remain expressible.
21 Priority,
22 /// The new-issue templates configured in `[templates.<name>]`
23 /// (tui-new-issue-mode). Single-select: choosing one names a record to
24 /// pre-fill the form from.
25 Template,
26 /// The parent issue (WP06 / T026). Single-select, per C-001: one parent,
27 /// never a list, so this is `DropdownState::single` below and never `multi`.
28 Parent,
29 /// The issue's project associations (WP06 T029). **Multi-select**, because the
30 /// model and the CLI's `--projects` both hold a list, and the options come from
31 /// the project **service** ([`crate::projects::list_projects`]), not from
32 /// anything the form constructs. No inline create: a project is a registry
33 /// record with an identity, and `tix project create` is where one is made.
34 Project,
35 /// The issue's epic references (WP06 T031). **Multi-select**, the exact analogue
36 /// of `Project` for the epic registry (C-003): the model and the CLI's `--epics`
37 /// both hold a list, and the options come from the epic **service**
38 /// ([`crate::epics::list_epics`]). No inline create: an epic is a registry record
39 /// with an identity, and `tix epic create` is where one is made.
40 Epic,
41}
42
43#[derive(Debug, Clone)]
44pub(in crate::app) struct FormPicker {
45 pub(in crate::app) field: FormPickerField,
46 pub(in crate::app) state: DropdownState,
47}
48
49/// The prefix on a stable-ID template's picker option id (`[issue_templates.<id>]`).
50///
51/// A stable template ID and a legacy `[templates.<name>]` name live in two
52/// config tables that are documented never to share a key space, but a single
53/// picker presents both — so each option id is namespaced here and decoded on
54/// selection. That keeps "a name in one is never mistaken for an ID in the
55/// other" true *inside the picker* as well (registry module doc), and it is why
56/// the form stores a bare ID/name rather than this opaque token.
57pub(in crate::app) const STABLE_TEMPLATE_PREFIX: &str = "stable:";
58
59/// The prefix on a legacy `[templates.<name>]` picker option id. See
60/// [`STABLE_TEMPLATE_PREFIX`].
61pub(in crate::app) const LEGACY_TEMPLATE_PREFIX: &str = "legacy:";
62
63impl FormPickerField {
64 /// Whether the picker's `+` inline create is offered on this field.
65 ///
66 /// Every field but Status keeps the keystroke it had: cycles, milestones and
67 /// team members become configuration records, labels and priorities are
68 /// preserved values, and parent/template simply no-op downstream. A board
69 /// column cannot be created from the form (tix #1508) — a status the board
70 /// does not configure renders in the Mismatched column, so the dropdown must
71 /// not pretend to add one.
72 pub(in crate::app) fn allows_inline_create(self) -> bool {
73 // A project is a registry record with an identity the service owns, so the
74 // form may no more invent one than it may invent a board column: `+` here
75 // would be a project `tix project list` never shows.
76 !matches!(
77 self,
78 FormPickerField::Status | FormPickerField::Project | FormPickerField::Epic
79 )
80 }
81}
82
83impl App {
84 pub(in crate::app) fn handle_form_picker_key(&mut self, key: KeyEvent) -> Result<Action> {
85 if matches!(key.code, KeyCode::Esc | KeyCode::F(2)) {
86 self.form_picker = None;
87 return Ok(Action::None);
88 }
89 // The Status dropdown refuses inline create (tix #1508): a board column the
90 // form invents is not a column the board has, and it would render in the
91 // Mismatched column rather than somewhere real. Swallow the create keys
92 // before `DropdownState::handle_key` turns them into an InlineCreate prompt.
93 if let Some(picker) = self.form_picker.as_ref()
94 && !picker.field.allows_inline_create()
95 && picker.state.mode != PickerMode::InlineCreate
96 && matches!(key.code, KeyCode::Char('+') | KeyCode::Insert)
97 {
98 return Ok(Action::None);
99 }
100 let event = match self.form_picker.as_mut() {
101 Some(picker) => picker.state.handle_key(key.code),
102 None => return Ok(Action::None),
103 };
104 match event {
105 PickerEvent::None => {}
106 PickerEvent::Cancelled => {
107 self.form_picker = None;
108 }
109 PickerEvent::Selected(ids) => {
110 self.apply_picker_selection(ids);
111 }
112 PickerEvent::Created(value) => {
113 self.apply_picker_created(value)?;
114 }
115 }
116 Ok(Action::None)
117 }
118
119 pub(in crate::app) fn open_form_picker(&mut self) {
120 let Some(form) = self.form.as_ref() else {
121 return;
122 };
123 let field = match form.focus {
124 form::FormFocus::Status => FormPickerField::Status,
125 form::FormFocus::Priority => FormPickerField::Priority,
126 form::FormFocus::Labels => FormPickerField::Labels,
127 form::FormFocus::Assignee => FormPickerField::Assignee,
128 form::FormFocus::Cycles => FormPickerField::Cycles,
129 form::FormFocus::Milestones => FormPickerField::Milestones,
130 form::FormFocus::Reviewer => FormPickerField::Reviewer,
131 form::FormFocus::Parent => FormPickerField::Parent,
132 form::FormFocus::Projects => FormPickerField::Project,
133 form::FormFocus::Epics => FormPickerField::Epic,
134 form::FormFocus::Template => FormPickerField::Template,
135 _ => return,
136 };
137 // The template picker has its own opener (`open_template_picker`), which
138 // seeds the `(none)` option and refuses when nothing is configured; F2 on
139 // the row routes there rather than duplicating the fallback here.
140 if field == FormPickerField::Template {
141 self.open_template_picker();
142 return;
143 }
144 let options = self.picker_options(field);
145 let mut state = match field {
146 FormPickerField::Labels
147 | FormPickerField::Cycles
148 | FormPickerField::Milestones
149 | FormPickerField::Project
150 | FormPickerField::Epic => DropdownState::multi(options),
151 FormPickerField::Assignee
152 | FormPickerField::Reviewer
153 | FormPickerField::Parent
154 | FormPickerField::Status
155 | FormPickerField::Priority
156 | FormPickerField::Template => DropdownState::single(options),
157 };
158 self.seed_picker_selection(field, &mut state);
159 self.form_picker = Some(FormPicker { field, state });
160 }
161
162 #[allow(clippy::too_many_lines)]
163 pub(in crate::app) fn picker_options(&self, field: FormPickerField) -> Vec<PickerItem> {
164 match field {
165 FormPickerField::Cycles => {
166 let root = self.project_root();
167 let planner = Planner::from_config(self.store.config.clone(), root);
168 planner
169 .cycle_names()
170 .into_iter()
171 .map(|name| PickerItem {
172 id: name.clone(),
173 label: name,
174 })
175 .collect()
176 }
177 FormPickerField::Milestones => {
178 let root = self.project_root();
179 let planner = Planner::from_config(self.store.config.clone(), root);
180 planner
181 .milestone_names()
182 .unwrap_or_default()
183 .into_iter()
184 .map(|name| PickerItem {
185 id: name.clone(),
186 label: name,
187 })
188 .collect()
189 }
190 FormPickerField::Labels => {
191 let mut out = std::collections::BTreeSet::new();
192 for issue in self.store.list().unwrap_or_default() {
193 for label in issue.labels {
194 if !label.trim().is_empty() {
195 out.insert(label);
196 }
197 }
198 }
199 out.into_iter()
200 .map(|label| PickerItem {
201 id: label.clone(),
202 label,
203 })
204 .collect()
205 }
206 // The board's columns, in board order, plus the field's current value
207 // when the board no longer configures it. A status the board has
208 // dropped must not become unselectable from a form that still holds
209 // it (tix #1508) — the same "keep the current value" rule the assignee
210 // and reviewer pickers follow.
211 FormPickerField::Status => {
212 let columns = self.active_board_config().columns.clone();
213 let mut options = crate::ui::triage::status_options(&columns);
214 if let Some(form) = self.form.as_ref() {
215 let current = form.status.trim();
216 if !current.is_empty() && !columns.iter().any(|c| c == current) {
217 options.push(PickerItem {
218 id: current.to_string(),
219 label: current.to_string(),
220 });
221 }
222 }
223 options
224 }
225 // The built-in set first, then every priority the store already uses,
226 // then the field's current value — the same "defaults plus what the
227 // project actually uses" rule Labels follows. A custom priority is a
228 // preserved model value (`Priority::Other`), so it must be listed once
229 // an issue carries it rather than being re-typed from memory.
230 FormPickerField::Priority => {
231 const BUILT_INS: [&str; 3] = ["low", "medium", "high"];
232 let mut out: Vec<String> = BUILT_INS.iter().map(|p| (*p).to_string()).collect();
233 for issue in self.store.list().unwrap_or_default() {
234 let priority = issue.priority.to_string();
235 if !priority.trim().is_empty() && !out.contains(&priority) {
236 out.push(priority);
237 }
238 }
239 if let Some(form) = self.form.as_ref() {
240 let current = form.priority.trim().to_string();
241 if !current.is_empty() && !out.contains(¤t) {
242 out.push(current);
243 }
244 }
245 out.into_iter()
246 .map(|priority| PickerItem {
247 id: priority.clone(),
248 label: priority,
249 })
250 .collect()
251 }
252 FormPickerField::Parent => {
253 // Every issue in the store is a candidate parent.
254 //
255 // T026's note asks for the issue itself to be excluded from the
256 // list, as "the first line of defense against a self-parent". The
257 // form is **create-only** — `Mode::Form` has one entry point and it
258 // always builds a fresh `FormState` — so there is no issue-under-edit
259 // to exclude: the child does not exist yet and cannot be among its
260 // own ancestors. The list is therefore unfiltered here, and the
261 // defense is `hierarchy::validate_new_parent`, which runs at submit.
262 // (Tracked as tix #1384: once an edit form exists, this arm must
263 // exclude the edited issue, and `set_parent` becomes the write.)
264 self.store
265 .list()
266 .unwrap_or_default()
267 .into_iter()
268 .map(|issue| PickerItem {
269 id: issue.number.to_string(),
270 label: format!("#{} {}", issue.number, issue.title),
271 })
272 .collect()
273 }
274 // Unreachable in practice: `open_form_picker` routes the Template row
275 // to `open_template_picker`, which owns the `(none)` option. Kept
276 // exhaustive and honest — the options *are* the configured templates.
277 FormPickerField::Template => self.template_picker_options(),
278 // The **service's** project list, in declaration order. The option id is
279 // the resolved project id (what the model stores and what the filter
280 // matches), and the label is the shared `name (id)` display form, so the
281 // picker and `tix project list` cannot spell a project two ways. Nothing
282 // here constructs or validates a project: a reference the registry does
283 // not hold never appears, and a stored reference that no longer resolves
284 // is kept visible by the current-value rule below
285 // (`seed_picker_selection` seeds it, and `DropdownState` lists a seeded
286 // value even when no option carries it).
287 FormPickerField::Project => {
288 let mut options: Vec<PickerItem> = self
289 .live_projects()
290 .into_iter()
291 .map(|project| PickerItem {
292 id: project.id.to_string(),
293 label: crate::projects::project_display(&project.id, &project.name),
294 })
295 .collect();
296 if let Some(form) = self.form.as_ref() {
297 for current in form.projects.split(',').map(str::trim) {
298 if !current.is_empty() && !options.iter().any(|o| o.id == current) {
299 options.push(PickerItem {
300 id: current.to_string(),
301 label: format!("{current} (missing project)"),
302 });
303 }
304 }
305 }
306 options
307 }
308 // The **service's** epic list, in declaration order — the exact analogue
309 // of the Project arm above. The option id is the resolved epic id (what
310 // the model stores and what the filter matches), and the label is the
311 // shared `name (id)` display form, so the picker and `tix epic list`
312 // cannot spell an epic two ways. Nothing here constructs or validates an
313 // epic: a reference the registry does not hold never appears, and a stored
314 // reference that no longer resolves is kept visible by the current-value
315 // rule below.
316 //
317 // No "exclude the issue itself" filter, and the reason is the Parent
318 // picker's: the form is **create-only** (`Mode::Form` always builds a
319 // fresh `FormState`), so there is no issue-under-edit to exclude, and an
320 // issue→epic reference cannot close a cycle in the epic **parent**
321 // hierarchy anyway — the two are different graphs (contracts/epic-
322 // service.md §3). A reference the service rejects (an unknown id, or a
323 // cycle a future edit form introduces) is refused at submit through the
324 // service's own diagnostic, which is the rule this row must not restate.
325 FormPickerField::Epic => {
326 let mut options: Vec<PickerItem> = self
327 .live_epics()
328 .into_iter()
329 .map(|epic| PickerItem {
330 id: epic.id.to_string(),
331 label: crate::epics::epic_display(&epic.id, &epic.name),
332 })
333 .collect();
334 if let Some(form) = self.form.as_ref() {
335 for current in form.epics.split(',').map(str::trim) {
336 if !current.is_empty() && !options.iter().any(|o| o.id == current) {
337 options.push(PickerItem {
338 id: current.to_string(),
339 label: format!("{current} (missing epic)"),
340 });
341 }
342 }
343 }
344 options
345 }
346 FormPickerField::Assignee | FormPickerField::Reviewer => {
347 let mut out = std::collections::BTreeSet::new();
348 for team in self.store.config.teams.values() {
349 for member in &team.members {
350 if !member.trim().is_empty() {
351 out.insert(member.trim().to_string());
352 }
353 }
354 }
355 if let Some(id) = git_identity() {
356 out.insert(id.to_string());
357 }
358 if let Some(form) = self.form.as_ref() {
359 let current = match field {
360 FormPickerField::Assignee => form.assignee.trim(),
361 FormPickerField::Reviewer => form.reviewer.trim(),
362 _ => "",
363 };
364 if !current.is_empty() {
365 out.insert(current.to_string());
366 }
367 }
368 out.into_iter()
369 .map(|member| PickerItem {
370 id: member.clone(),
371 label: member,
372 })
373 .collect()
374 }
375 }
376 }
377
378 pub(in crate::app) fn seed_picker_selection(
379 &self,
380 field: FormPickerField,
381 picker: &mut DropdownState,
382 ) {
383 let Some(form) = self.form.as_ref() else {
384 return;
385 };
386 let values: Vec<String> = match field {
387 FormPickerField::Labels => form
388 .labels
389 .split(',')
390 .map(str::trim)
391 .filter(|s| !s.is_empty())
392 .map(str::to_string)
393 .collect(),
394 FormPickerField::Cycles => form
395 .cycles
396 .split(',')
397 .map(str::trim)
398 .filter(|s| !s.is_empty())
399 .map(str::to_string)
400 .collect(),
401 FormPickerField::Milestones => form
402 .milestones
403 .split(',')
404 .map(str::trim)
405 .filter(|s| !s.is_empty())
406 .map(str::to_string)
407 .collect(),
408 // WP06 T029 step 2: the pre-selected set is the issue's current
409 // associations, in the same comma-joined shape the submit path parses.
410 FormPickerField::Project => form
411 .projects
412 .split(',')
413 .map(str::trim)
414 .filter(|s| !s.is_empty())
415 .map(str::to_string)
416 .collect(),
417 // WP06 T031 step 2: the exact analogue for epic references — the
418 // pre-selected set is the issue's current references, so an edit cannot
419 // silently drop the ones the user did not touch.
420 FormPickerField::Epic => form
421 .epics
422 .split(',')
423 .map(str::trim)
424 .filter(|s| !s.is_empty())
425 .map(str::to_string)
426 .collect(),
427 FormPickerField::Assignee => form
428 .assignee
429 .trim()
430 .split(',')
431 .map(str::trim)
432 .filter(|s| !s.is_empty())
433 .map(str::to_string)
434 .take(1)
435 .collect(),
436 FormPickerField::Reviewer => form
437 .reviewer
438 .trim()
439 .split(',')
440 .map(str::trim)
441 .filter(|s| !s.is_empty())
442 .map(str::to_string)
443 .take(1)
444 .collect(),
445 // Status and Priority are single values, held whole (a status can
446 // contain spaces, so splitting on commas would be wrong), and an
447 // empty field simply starts the dropdown with nothing selected.
448 FormPickerField::Status => (!form.status.trim().is_empty())
449 .then(|| form.status.trim().to_string())
450 .into_iter()
451 .collect(),
452 FormPickerField::Priority => (!form.priority.trim().is_empty())
453 .then(|| form.priority.trim().to_string())
454 .into_iter()
455 .collect(),
456 // Already a resolved number, so no comma-splitting: there is exactly
457 // one parent, and splitting it would corrupt a value that is legal.
458 FormPickerField::Parent => (!form.parent.trim().is_empty())
459 .then(|| form.parent.trim().to_string())
460 .into_iter()
461 .collect(),
462 // Empty is `(none)`, which matches the option's empty id, so the
463 // picker opens on the current selection rather than at the top. A
464 // stable-ID selection is namespaced like its option; a legacy one
465 // likewise. See [`STABLE_TEMPLATE_PREFIX`].
466 FormPickerField::Template => {
467 match (form.template_scope.as_deref(), form.template.trim()) {
468 (Some(_), id) if !id.is_empty() => {
469 vec![format!("{STABLE_TEMPLATE_PREFIX}{id}")]
470 }
471 (None, name) if !name.is_empty() => {
472 vec![format!("{LEGACY_TEMPLATE_PREFIX}{name}")]
473 }
474 _ => Vec::new(),
475 }
476 }
477 };
478 match &mut picker.selection {
479 SelectionModel::Single(current) => {
480 *current = values.first().cloned();
481 }
482 SelectionModel::Multi(current) => {
483 current.clear();
484 current.extend(values);
485 }
486 }
487 }
488
489 pub(in crate::app) fn apply_picker_selection(&mut self, values: Vec<String>) {
490 let Some(field) = self.form_picker.as_ref().map(|p| p.field) else {
491 return;
492 };
493 // Templates are single-select *and* choosing one ends the interaction: the
494 // picker closes and the form is pre-filled. The multi-select fields below
495 // stay open on purpose, so several labels/cycles can be picked in one go.
496 if field == FormPickerField::Template {
497 let id = values.first().cloned().unwrap_or_default();
498 self.form_picker = None;
499 self.apply_template_choice(&id);
500 return;
501 }
502 let Some(form) = self.form.as_mut() else {
503 return;
504 };
505 match field {
506 FormPickerField::Labels => form.labels = values.join(","),
507 FormPickerField::Assignee => {
508 form.assignee = values.first().cloned().unwrap_or_default()
509 }
510 FormPickerField::Cycles => form.cycles = values.join(","),
511 FormPickerField::Milestones => form.milestones = values.join(","),
512 // WP06 T029: the multi-selection is the association set, written back in
513 // the same comma-joined shape every other list field uses. The picker is
514 // the only way this row changes, and the ids came from the service, so
515 // the form has constructed nothing.
516 FormPickerField::Project => form.projects = values.join(","),
517 // WP06 T031: the multi-selection is the reference set, written back in
518 // the same comma-joined shape every other list field uses. The picker is
519 // the only way this row changes, and the ids came from the service, so
520 // the form has constructed nothing.
521 FormPickerField::Epic => form.epics = values.join(","),
522 // Single-select: a Status/Priority dropdown writes the one chosen
523 // value, and no selection clears the row, which is the same "empty
524 // means unset" the CLI accepts.
525 FormPickerField::Status => form.status = values.first().cloned().unwrap_or_default(),
526 FormPickerField::Priority => {
527 form.priority = values.first().cloned().unwrap_or_default()
528 }
529 FormPickerField::Reviewer => {
530 form.reviewer = values.first().cloned().unwrap_or_default()
531 }
532 // Single-select: at most one value, and no selection clears the field,
533 // which is the same "empty means none" the CLI's `--parent ""` means.
534 FormPickerField::Parent => form.parent = values.first().cloned().unwrap_or_default(),
535 // Handled before the `form` borrow above: choosing a template closes
536 // the picker and re-fills the form through `apply_template_choice`.
537 FormPickerField::Template => {}
538 }
539 // A picker replaces the value wholesale, so the insertion point belongs
540 // after it rather than wherever it sat in the text it replaced.
541 form.cursor_to_end();
542 }
543
544 pub(in crate::app) fn apply_picker_created(&mut self, value: String) -> Result<()> {
545 let value = value.trim().to_string();
546 if value.is_empty() {
547 return Ok(());
548 }
549 let Some(field) = self.form_picker.as_ref().map(|p| p.field) else {
550 return Ok(());
551 };
552 match field {
553 FormPickerField::Cycles => {
554 let root = self.project_root();
555 let mut planner = Planner::from_config(self.store.config.clone(), root.clone());
556 match planner.add_cycle(Some(value.clone())) {
557 Ok(_) => {
558 self.reload_config_from(root.as_path())?;
559 }
560 Err(e) => {
561 self.status_msg = Some(e.to_string());
562 return Ok(());
563 }
564 }
565 }
566 FormPickerField::Milestones => {
567 let root = self.project_root();
568 let planner = Planner::from_config(self.store.config.clone(), root.clone());
569 match planner.create_milestone_file(&value, None, None) {
570 Ok(_) => {
571 self.reload_config_from(root.as_path())?;
572 }
573 Err(e) => {
574 self.status_msg = Some(e.to_string());
575 return Ok(());
576 }
577 }
578 }
579 FormPickerField::Assignee | FormPickerField::Reviewer => {
580 let root = self.project_root();
581 let mut planner = Planner::from_config(self.store.config.clone(), root.clone());
582 let team = self
583 .store
584 .config
585 .planning
586 .default_team
587 .clone()
588 .unwrap_or_else(|| "default".to_string());
589 match planner.add_team_member(&team, &value) {
590 Ok(()) => {
591 self.reload_config_from(root.as_path())?;
592 }
593 Err(e) => {
594 self.status_msg = Some(e.to_string());
595 return Ok(());
596 }
597 }
598 }
599 // `+` is inline-create: it makes a *new* cycle, milestone, or team
600 // member, and appends it to the list. A parent is not something you
601 // make from inside another issue's form — it is an issue that already
602 // exists — so the keystroke does nothing here, exactly as it does for
603 // Labels. Creating an empty issue to stand in for a parent would be
604 // worse than nothing: it would write a file the user never asked for.
605 // Templates are configuration, not form values: `+` cannot invent one
606 // any more than it can invent a parent. It is edited in `tix.toml`.
607 // A priority has no configuration record to write either — the custom
608 // value *is* the form value, and it is applied through the selection
609 // below like any picked option. Status never reaches here: its dropdown
610 // refuses `+`, because a column the board does not configure would
611 // render in the Mismatched column rather than becoming a choice.
612 FormPickerField::Labels
613 | FormPickerField::Parent
614 | FormPickerField::Template
615 | FormPickerField::Status
616 | FormPickerField::Priority
617 | FormPickerField::Project
618 | FormPickerField::Epic => {}
619 }
620
621 let next_options = self.picker_options(field);
622 if let Some(picker) = self.form_picker.as_mut() {
623 picker.state.options = next_options;
624 picker.state.filter.clear();
625 picker.state.cursor = 0;
626 picker.state.mode = PickerMode::Idle;
627 picker.state.create_buffer.clear();
628 match &mut picker.state.selection {
629 SelectionModel::Single(current) => *current = Some(value.clone()),
630 SelectionModel::Multi(current) => {
631 current.insert(value.clone());
632 }
633 }
634 let values = match &picker.state.selection {
635 SelectionModel::Single(current) => current.iter().cloned().collect(),
636 SelectionModel::Multi(current) => current.iter().cloned().collect(),
637 };
638 self.apply_picker_selection(values);
639 }
640 Ok(())
641 }
642
643 /// The picker options for the Template row: the stable-ID registry
644 /// (`[issue_templates.<id>]`) first, then the legacy `[templates.<name>]`
645 /// pre-fill names.
646 ///
647 /// Each option's id is namespaced ([`STABLE_TEMPLATE_PREFIX`] /
648 /// [`LEGACY_TEMPLATE_PREFIX`]) so the two config key spaces stay distinct
649 /// inside one picker. A stable label shows the template's **display name and
650 /// explicit scope** — registry metadata presented for selection, never
651 /// interpreted (WP05/T023, T026). The stable ID is what a selection records,
652 /// never the display name (C-002).
653 pub(in crate::app) fn template_picker_options(&self) -> Vec<PickerItem> {
654 let mut options: Vec<PickerItem> = self
655 .store
656 .config
657 .issue_templates
658 .iter()
659 .map(|(id, template)| PickerItem {
660 id: format!("{STABLE_TEMPLATE_PREFIX}{id}"),
661 label: format!(
662 "{} — {} [{}]",
663 id,
664 template.display_name(id),
665 template.scope
666 ),
667 })
668 .collect();
669 options.extend(self.store.config.templates.keys().map(|name| PickerItem {
670 id: format!("{LEGACY_TEMPLATE_PREFIX}{name}"),
671 label: name.clone(),
672 }));
673 options
674 }
675}