Skip to main content

tix/ui/
bulk.rs

1//! The TUI batch surface (WP07).
2//!
3//! Everything here is a **state machine over shared services**. It builds no patch
4//! and writes no file: the selection goes through `bulk::Selection::from_issues`
5//! (WP01's constructor, built for exactly this), the preview through
6//! `bulk::plan::build_plan`, the apply through `bulk::apply`/`bulk::pair`/
7//! `bulk::archive`, and the text through `commands::bulk`'s **same** renderers the
8//! CLI uses.
9//!
10//! ## Why the TUI holds `PatchFlags` and not an `IssuePatch`
11//!
12//! T032 says to "reject TUI-local patch construction or direct writes". A TUI that
13//! built its own `IssuePatch` would be a second mapping from user input to a typed
14//! patch, free to drift from the CLI's — and the drift would show up as a TUI
15//! accepting a blank field as `Clear` while the CLI treats it as absent, which is
16//! precisely the bug WP06 found in the CLI.
17//!
18//! So the modal holds `commands::bulk::PatchFlags` — the **same** struct the CLI
19//! parses into — and the conversion happens in one place. The TUI's only job is to
20//! set flags and press apply.
21//!
22//! ## The board cursor is untouched
23//!
24//! T031 requires the existing single-card cursor and navigation semantics to stay
25//! intact. So the multi-selection is a **separate** ordered set, and the board's
26//! `(active_col, selected)` pair is never written by anything in this module. That is
27//! also what makes cancelling safe: the modal is a value, and dropping it restores
28//! the board exactly.
29//!
30//! ## Why there is a focus, and not one key channel
31//!
32//! A first draft had `p` preview, `y` confirm, `c` clear **and** bare characters as
33//! field text, all on one channel. That is not a shortcut, it is a bug: typing `p`
34//! into the status field previews instead of typing. Any single-channel design has
35//! this — the union of "characters a field may contain" and "keys that are commands"
36//! is the whole ASCII range.
37//!
38//! So the modal has [`BulkFocus`], the same two-state shape the settings prompt already
39//! uses (`InputContext::SettingsPrompt`). On a **row** the keys are commands; in
40//! **edit** they are text. `enter` moves between them, and the two never overlap.
41//!
42//! ## The same text as the CLI
43//!
44//! `render_preview` and `render_result` call `commands::bulk::render_plan` and
45//! `render_report`. `contracts/surface-parity.md` requires the same decision and the
46//! same message on every surface, and the cheapest way to guarantee that is not to
47//! have two renderers.
48
49use ratatui::Frame;
50use ratatui::layout::{Constraint, Direction, Layout, Rect};
51use ratatui::style::{Modifier, Style};
52use ratatui::text::{Line, Span};
53use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph};
54
55use crate::bulk::plan::BatchPlan;
56use crate::bulk::{
57    self, ApplyReport, ArchiveRequest, Confirmation, IssuePatch, RelationshipOp, Selection,
58};
59use crate::commands::bulk::{self as cli_bulk, Mode, PatchFlags};
60use crate::error::{Result, TixError};
61use crate::storage::Store;
62
63/// Which batch action a modal is for.
64///
65/// **Not** `cli::BulkAction`. That enum carries every flag as struct fields — the
66/// flags are the clap surface, and the modal *is* the flags. Storing it would put
67/// each flag in two places and let them disagree, which is the T032 prohibition
68/// ("reject TUI-local patch construction") in a different guise. Four words, and the
69/// `From` impl is the only conversion.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum BatchAction {
72    Edit,
73    Link,
74    Unlink,
75    Archive,
76}
77
78impl BatchAction {
79    /// The word a user reads.
80    pub fn as_str(self) -> &'static str {
81        match self {
82            Self::Edit => "edit",
83            Self::Link => "link",
84            Self::Unlink => "unlink",
85            Self::Archive => "archive",
86        }
87    }
88
89    /// Whether this action takes field flags, as opposed to a target and a kind.
90    pub fn takes_fields(self) -> bool {
91        matches!(self, Self::Edit)
92    }
93
94    /// Whether this action is a pair.
95    pub fn is_pair(self) -> bool {
96        matches!(self, Self::Link | Self::Unlink)
97    }
98
99    /// The next action, for the cycle key.
100    ///
101    /// A fixed order — edit, link, unlink, archive — so `a` twice returns to where
102    /// it started. A cycle whose order depended on the board would make `a` a
103    /// non-repeatable key, and a non-repeatable cycle is a key nobody uses twice.
104    pub fn next(self) -> Self {
105        match self {
106            Self::Edit => Self::Link,
107            Self::Link => Self::Unlink,
108            Self::Unlink => Self::Archive,
109            Self::Archive => Self::Edit,
110        }
111    }
112}
113
114impl From<&crate::cli::BulkAction> for BatchAction {
115    fn from(action: &crate::cli::BulkAction) -> Self {
116        match action {
117            crate::cli::BulkAction::Edit { .. } => Self::Edit,
118            crate::cli::BulkAction::Link { .. } => Self::Link,
119            crate::cli::BulkAction::Unlink { .. } => Self::Unlink,
120            crate::cli::BulkAction::Archive { .. } => Self::Archive,
121        }
122    }
123}
124
125/// The largest a batch selection may be.
126///
127/// A bound, and stated, because a TUI selection is built by **toggling cards** and
128/// there is no "press enter to start" moment at which a user has said "all of these".
129/// A board of 1200 issues would let a user mark 1200 of them with 1200 keystrokes
130/// and then be asked to confirm a 1200-item write with a `y`.
131///
132/// So the selection is bounded, `toggle` **refuses** past the bound rather than
133/// silently dropping, and the refusal says the bound. A bound the user cannot reach
134/// is worse than no bound, so it is a refusal with a number in it — and the number is
135/// this constant, not a literal at the call site.
136pub const MAX_BATCH_SELECTION: usize = 200;
137
138/// Why a selection operation was refused.
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub enum SelectionProblem {
141    /// The bound above.
142    TooLarge,
143    /// The same issue twice.
144    AlreadySelected,
145}
146
147impl SelectionProblem {
148    pub fn as_str(self) -> &'static str {
149        match self {
150            Self::TooLarge => "selection_full",
151            Self::AlreadySelected => "already_selected",
152        }
153    }
154}
155
156impl std::fmt::Display for SelectionProblem {
157    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158        match self {
159            Self::TooLarge => write!(
160                f,
161                "a batch selection holds at most {MAX_BATCH_SELECTION} issues — \
162                 clear some first, or narrow the filter"
163            ),
164            Self::AlreadySelected => {
165                write!(f, "that issue is already selected — press `x` to remove it")
166            }
167        }
168    }
169}
170
171/// The multi-selection: an ordered, de-duplicated set of issue numbers.
172///
173/// **Order is the order the user marked them**, not the board's column order, and not
174/// numeric order. A user marks what they can see, and a plan that reorders what they
175/// marked is a plan they did not read.
176#[derive(Debug, Clone, Default, PartialEq, Eq)]
177pub struct BulkSelection {
178    numbers: Vec<u32>,
179}
180
181impl BulkSelection {
182    pub fn new() -> Self {
183        Self::default()
184    }
185
186    /// Add an issue, refusing a duplicate and refusing past the bound.
187    ///
188    /// Both refusals are `Err` rather than a silent `false`, because a silent `false`
189    /// for "too many" is a keystroke that appeared to do nothing, and a user
190    /// counting their selection has no way to know which press was dropped.
191    pub fn toggle(&mut self, number: u32) -> std::result::Result<bool, SelectionProblem> {
192        if self.numbers.contains(&number) {
193            return Err(SelectionProblem::AlreadySelected);
194        }
195        if self.numbers.len() >= MAX_BATCH_SELECTION {
196            return Err(SelectionProblem::TooLarge);
197        }
198        self.numbers.push(number);
199        Ok(true)
200    }
201
202    /// Remove an issue, reporting whether it was there.
203    ///
204    /// `bool` rather than `Result`: removing something that is not selected is the
205    /// *desired* end state, and a user pressing `x` twice means "not selected", not
206    /// "something went wrong".
207    pub fn remove(&mut self, number: u32) -> bool {
208        let before = self.numbers.len();
209        self.numbers.retain(|n| *n != number);
210        self.numbers.len() != before
211    }
212
213    /// Remove everything.
214    pub fn clear(&mut self) {
215        self.numbers.clear();
216    }
217
218    pub fn contains(&self, number: u32) -> bool {
219        self.numbers.contains(&number)
220    }
221
222    /// The numbers, in the order the user marked them.
223    pub fn numbers(&self) -> &[u32] {
224        &self.numbers
225    }
226
227    pub fn len(&self) -> usize {
228        self.numbers.len()
229    }
230
231    pub fn is_empty(&self) -> bool {
232        self.numbers.is_empty()
233    }
234
235    /// The one-line header a surface shows.
236    ///
237    /// The count **and** the bound, so a user who hit the ceiling can see where they
238    /// are without counting the marks themselves.
239    pub fn summary(&self) -> String {
240        if self.numbers.is_empty() {
241            return "no issues selected — press `space` on a card, `X` to clear".to_string();
242        }
243        let list: Vec<String> = self.numbers.iter().map(u32::to_string).collect();
244        format!(
245            "{} selected (#{}) of at most {MAX_BATCH_SELECTION}",
246            self.numbers.len(),
247            list.join(", ")
248        )
249    }
250
251    /// The `Selection` a plan is built from, in the marked order.
252    ///
253    /// Takes the issues rather than a store, because the TUI already has them: it
254    /// has read the board to draw the cards. Re-resolving through selectors would be
255    /// a second selection path, and it would produce a *different* order.
256    ///
257    /// Issues whose number is not in `issues` are **dropped** rather than refused.
258    /// The board may have been filtered between marking and previewing, and a
259    /// preview that refuses because a card went out of view is a preview the user
260    /// cannot get at all. The dropped number is reported in the note, so "3 of your
261    /// 5 are still visible" is visible rather than inferred.
262    pub fn to_selection(&self, issues: &[(u32, crate::model::Issue)]) -> (Selection, Vec<u32>) {
263        let mut kept = Vec::new();
264        let mut dropped = Vec::new();
265        for number in &self.numbers {
266            if issues.iter().any(|(n, _)| n == number) {
267                kept.push(*number);
268            } else {
269                dropped.push(*number);
270            }
271        }
272        let selection = Selection::from_issues(
273            kept.iter().filter_map(|number| {
274                issues
275                    .iter()
276                    .find(|(n, _)| n == number)
277                    .map(|(_, issue)| issue.clone())
278            }),
279            bulk::SelectionSource::Explicit,
280            bulk::SelectionScope::Active,
281        );
282        (selection, dropped)
283    }
284}
285
286/// Where the modal is in its own cycle.
287#[derive(Debug, Clone, PartialEq, Eq)]
288pub enum BulkState {
289    /// Choosing issues.
290    Selecting,
291    /// A plan is built and shown. Cancelling here changes nothing — it never has.
292    Preview(Box<BatchPlan>),
293    /// The plan is shown and awaiting an explicit confirm. **Separate** from
294    /// `Preview` so the modal can render a different affordance, and so "I saw it" and
295    /// "I approved it" are two different states in the record rather than one.
296    Confirming(Box<BatchPlan>),
297    /// The batch ran. Per-item outcomes are in the report.
298    Done(Box<ApplyReport>, String),
299    /// The batch was refused before or during the apply.
300    Refused(String),
301    /// An action needs something the user has not given (a pair's target).
302    NeedsInput(&'static str),
303}
304
305/// Which channel the keys belong to.
306#[derive(Debug, Clone, Copy, PartialEq, Eq)]
307pub enum BulkFocus {
308    /// The key is a command (`p`, `y`, `a`, `x`, `X`, `c`).
309    Row,
310    /// The key is text for the row under the cursor.
311    Edit,
312}
313
314/// The batch modal.
315#[derive(Debug, Clone)]
316pub struct BulkModal {
317    pub action: BatchAction,
318    /// The **CLI's** flag struct, not a locally built patch (see the module docs).
319    pub flags: PatchFlags,
320    /// The other end of a pair.
321    pub target: Option<String>,
322    /// The relationship kind, as the user will set it.
323    pub kind: Option<String>,
324    /// Which row the cursor is on.
325    pub cursor: usize,
326    pub focus: BulkFocus,
327    /// The text being typed, while `focus` is [`BulkFocus::Edit`].
328    ///
329    /// A draft is **discarded** on `esc`, never committed. An earlier draft of this
330    /// modal snapshotted the row on `begin_edit` and restored it on `esc`; the
331    /// snapshot was dead — `commit_edit` is the only thing that writes a row, so the
332    /// restore and a clear were indistinguishable from outside. The revert-and-red
333    /// run is what proved it: a mutation replacing the restore with a clear left all
334    /// 141 cases green.
335    pub draft: String,
336    pub selection: BulkSelection,
337    pub state: BulkState,
338    /// The last message, for the status bar.
339    pub note: Option<String>,
340    /// Whether the plan no longer matches the screen.
341    ///
342    /// Set on the first preview and on **any** change to the selection or the flags,
343    /// so a confirm can never apply a plan the user has not looked at.
344    pub plan_stale: bool,
345}
346
347impl BulkModal {
348    /// A fresh modal for `action`.
349    pub fn new(action: BatchAction) -> Self {
350        Self {
351            action,
352            flags: PatchFlags::default(),
353            target: None,
354            kind: None,
355            cursor: 0,
356            focus: BulkFocus::Row,
357            draft: String::new(),
358            selection: BulkSelection::new(),
359            state: BulkState::Selecting,
360            note: None,
361            plan_stale: true,
362        }
363    }
364
365    /// The row names, in the order `tab` walks them.
366    ///
367    /// Depends on the action, and that is the point: an archive has **no** rows,
368    /// because `ArchiveRequest::archive()` names no target status and inventing a
369    /// field for one would be offering a restore that #190's C-003 does not allow.
370    /// A pair has no field rows either — it has a target and a kind.
371    pub fn row_names(&self) -> Vec<&'static str> {
372        let mut names: Vec<&'static str> = if self.action.takes_fields() {
373            PatchFlags::field_rows().to_vec()
374        } else {
375            Vec::new()
376        };
377        if self.action.is_pair() {
378            names.push("target");
379            names.push("kind");
380        }
381        names
382    }
383
384    /// The name of the row at `index`, for the render.
385    pub fn row_name(&self, index: usize) -> &'static str {
386        self.row_names()
387            .into_iter()
388            .nth(index)
389            .unwrap_or("selection")
390    }
391
392    /// The value of the row at `index`.
393    ///
394    /// An absent value is the em dash, not an empty string, so a blank row is
395    /// visibly blank rather than looking like a field set to `""`. The distinction is
396    /// the whole WP06 finding: a blank `--estimate ""` is `Unchanged`.
397    pub fn row_value(&self, index: usize) -> String {
398        let flags = &self.flags;
399        match self.row_name(index) {
400            "status" => flags.status.clone(),
401            "priority" => flags.priority.clone(),
402            "assignee" => flags.assignee.clone(),
403            "labels" => flags.labels.clone(),
404            "start-date" => flags.start_date.clone(),
405            "due-date" => flags.due_date.clone(),
406            "estimate" => flags.estimate.clone(),
407            "cycles" => flags.cycles.clone(),
408            "milestones" => flags.milestones.clone(),
409            "clear" => (!flags.clear.is_empty()).then(|| flags.clear.join(" ")),
410            "target" => self.target.clone(),
411            "kind" => self.kind.clone(),
412            _ => None,
413        }
414        .unwrap_or_else(|| "—".to_string())
415    }
416
417    /// Store `value` on the row at `index`, or say why it cannot be stored.
418    ///
419    /// A blank value is stored as **absent** (`None`), which is what makes "the user
420    /// cleared the field" and "the user never touched it" the same request — the
421    /// contract's `Unchanged`. The `clear` row is the *explicit* way to empty
422    /// something, and it is the only row that may name a field `clear` does not own.
423    pub fn set_row(&mut self, index: usize, value: String) -> Result<()> {
424        let value = value.trim().to_string();
425        let name = self.row_name(index);
426        let flags = &mut self.flags;
427        let slot = match name {
428            "status" => &mut flags.status,
429            "priority" => &mut flags.priority,
430            "assignee" => &mut flags.assignee,
431            "labels" => &mut flags.labels,
432            "start-date" => &mut flags.start_date,
433            "due-date" => &mut flags.due_date,
434            "estimate" => &mut flags.estimate,
435            "cycles" => &mut flags.cycles,
436            "milestones" => &mut flags.milestones,
437            "clear" => {
438                if value.is_empty() {
439                    flags.clear.clear();
440                    self.mark_stale();
441                    return Ok(());
442                }
443                if !PatchFlags::is_clearable(&value) {
444                    return Err(TixError::InvalidInput(format!(
445                        "cannot clear '{value}' — clearable fields: {}",
446                        PatchFlags::clearable().join(", ")
447                    )));
448                }
449                // One `--clear` per invocation, exactly as the CLI carries it. A second
450                // field is **refused**, not appended and not dropped — a `--clear a
451                // --clear b` that silently honoured only `a` is a request half-met.
452                if !flags.clear.is_empty() && !flags.clear.contains(&value) {
453                    return Err(TixError::InvalidInput(format!(
454                        "one batch clears one field — '{value}' is clearable, and '{}' is \
455                         already named; clear them one run at a time",
456                        flags.clear[0]
457                    )));
458                }
459                flags.clear = vec![value];
460                self.mark_stale();
461                return Ok(());
462            }
463            "target" => {
464                let slot = &mut self.target;
465                *slot = (!value.is_empty()).then_some(value);
466                self.mark_stale();
467                return Ok(());
468            }
469            "kind" => {
470                let slot = &mut self.kind;
471                *slot = (!value.is_empty()).then_some(value);
472                self.mark_stale();
473                return Ok(());
474            }
475            _ => return Ok(()),
476        };
477        *slot = (!value.is_empty()).then_some(value);
478        self.mark_stale();
479        Ok(())
480    }
481
482    /// Mark that the plan no longer matches the screen.
483    ///
484    /// `contracts/patch-plan.md` §4: "Apply requires the exact confirmed plan … any
485    /// change to selection or patch requires a new preview." So a confirm is refused
486    /// while stale, and the refusal says *why* rather than silently re-previewing — a
487    /// silent re-preview would apply something the user did not look at.
488    pub fn mark_stale(&mut self) {
489        self.plan_stale = true;
490    }
491
492    /// Begin editing the row under the cursor.
493    pub fn begin_edit(&mut self) {
494        let row = self.cursor;
495        self.draft = self.row_value(row);
496        // A row that was never set starts empty, not showing the em dash the user
497        // would then have to delete before typing.
498        if self.draft == "—" {
499            self.draft.clear();
500        }
501        self.focus = BulkFocus::Edit;
502    }
503
504    /// Commit the draft, or report why it cannot be committed.
505    pub fn commit_edit(&mut self) -> Result<()> {
506        let row = self.cursor;
507        let draft = std::mem::take(&mut self.draft);
508        self.focus = BulkFocus::Row;
509        self.set_row(row, draft)
510    }
511
512    /// Abandon the draft. The row keeps whatever it had.
513    ///
514    /// Discarding rather than committing is the point: an abandoned edit that still
515    /// wrote the half-typed value is an abandoned edit that changed state. The draft
516    /// is thrown away rather than left in place, because `begin_edit` rebuilds it
517    /// from the row every time — so a leftover draft is a draft nothing can read.
518    pub fn abort_edit(&mut self) {
519        self.draft.clear();
520        self.focus = BulkFocus::Row;
521    }
522
523    /// Move the cursor, wrapping. A no-op when the action has no rows (an archive).
524    pub fn move_cursor(&mut self, delta: isize) {
525        let count = self.row_names().len();
526        if count == 0 {
527            self.cursor = 0;
528            return;
529        }
530        let len = count as isize;
531        let next = (self.cursor as isize + delta).rem_euclid(len);
532        self.cursor = next as usize;
533    }
534
535    /// The typed patch for a **field** action, or say why it cannot be built.
536    ///
537    /// Delegates to the CLI's own conversion, so a TUI field and a CLI flag mean the
538    /// same thing — including the "a blank field is `Unchanged`, not `Clear`" rule
539    /// WP06 found. A TUI that built its own patch would have its own blank rule.
540    pub fn patch(&self) -> Result<IssuePatch> {
541        self.flags.to_patch()
542    }
543
544    /// The typed patch for **this** action, or say why it cannot be built.
545    ///
546    /// One method, because the preview and the confirm both need it, and two
547    /// `match self.action` blocks are two places for an action to be handled
548    /// differently.
549    pub fn patch_for(&self) -> Result<IssuePatch> {
550        Ok(match self.action {
551            BatchAction::Edit => self.patch()?,
552            BatchAction::Link | BatchAction::Unlink => IssuePatch {
553                relationship: Some(self.relationship_for(&[], None)?),
554                ..IssuePatch::default()
555            },
556            BatchAction::Archive => IssuePatch {
557                archive: Some(self.archive_request()),
558                ..IssuePatch::default()
559            },
560        })
561    }
562
563    /// Build a plan for the current selection. Read-only by construction: it
564    /// resolves, validates and renders a preview, and writes nothing.
565    pub fn preview(&mut self, store: &Store, issues: &[(u32, crate::model::Issue)]) -> Result<()> {
566        if self.selection.is_empty() {
567            self.state = BulkState::NeedsInput("select at least one issue with `space`");
568            return Ok(());
569        }
570        // A request the modal cannot turn into a plan is a **refusal**, not an `Err`.
571        // WP06 froze that rule for the CLI — "a refusal is a completed command that
572        // reports non-zero, its reasons go to stdout" — and the TUI is a surface of
573        // the same command, so it gets the same rule. An `Err` out of `preview` would
574        // leave the event loop with a `Result` to do something about, and the only
575        // sensible thing it could do is quit: a user who pressed `p` with no field set
576        // would lose their board.
577        let patch = match self.patch_for() {
578            Ok(patch) => patch,
579            Err(e) => {
580                self.state = BulkState::Refused(e.to_string());
581                self.note = Some(e.to_string());
582                return Ok(());
583            }
584        };
585        let (selection, dropped) = self.selection.to_selection(issues);
586        if selection.is_empty() {
587            self.state = BulkState::NeedsInput("none of the selected issues are on the board");
588            return Ok(());
589        }
590        let plan = match bulk::plan::build_plan(
591            &store.config,
592            &store.issues_dir,
593            selection,
594            patch,
595            |op: &RelationshipOp| bulk::pair::plan_for(store, op),
596        )
597        .map(|mut plan| {
598            // WP07 T035: the modal's preview shows the same WIP decisions the apply
599            // will, from the **store's** issues (a column counts every card in it,
600            // not only the selected ones).
601            crate::wip::bulk::annotate_plan(
602                &mut plan,
603                &store.config,
604                &store.list().unwrap_or_default(),
605            );
606            plan
607        }) {
608            Ok(plan) => plan,
609            Err(e) => {
610                self.state = BulkState::Refused(e.to_string());
611                self.note = Some(e.to_string());
612                return Ok(());
613            }
614        };
615        self.note = (!dropped.is_empty()).then(|| {
616            format!(
617                "{} selected issue(s) are no longer on the board and were left out: #{}",
618                dropped.len(),
619                dropped
620                    .iter()
621                    .map(u32::to_string)
622                    .collect::<Vec<_>>()
623                    .join(", #")
624            )
625        });
626        self.state = BulkState::Preview(Box::new(plan));
627        self.plan_stale = false;
628        Ok(())
629    }
630
631    /// The relationship operation for `numbers`, or the field the user has not filled
632    /// in.
633    ///
634    /// A pair's `from` is **per selected issue**, not the whole selection: `from` is a
635    /// single issue in the relationship vocabulary, and the batch is the set of
636    /// edges. Passing the whole selection as `from` would build a plan for an edge
637    /// that does not exist.
638    pub fn relationship_for(
639        &self,
640        numbers: &[u32],
641        kind_override: Option<String>,
642    ) -> Result<RelationshipOp> {
643        let target = self
644            .target
645            .as_deref()
646            .map(str::trim)
647            .filter(|t| !t.is_empty())
648            .ok_or_else(|| {
649                TixError::InvalidInput("a pair needs a target — edit the `target` row".to_string())
650            })?;
651        // The kind is the **service's** vocabulary, through its own parser, so a TUI
652        // kind is a value `tix link --type` would accept.
653        let kind = match kind_override {
654            Some(raw) => crate::commands::link::require_kind(Some(raw.as_str()))?,
655            None => crate::commands::link::require_kind(self.kind.as_deref())?,
656        };
657        // A preview of a whole selection has no single `from`; the caller pairs each
658        // issue separately. An empty `numbers` means "the caller is previewing the
659        // shape", and the pair's target and kind are all it needs.
660        let from = numbers.first().map(u32::to_string).unwrap_or_default();
661        Ok(if self.action == BatchAction::Unlink {
662            RelationshipOp::unlink(from, target, kind)
663        } else {
664            RelationshipOp::link(from, target, kind)
665        })
666    }
667
668    /// The archive request.
669    ///
670    /// The TUI's archive action is **archive only** — there is no restore verb on
671    /// this modal, because a restore needs an explicit target status (#190's C-003)
672    /// and there is no row for one. A modal that offered a restore with no target
673    /// would be offering the request #190 rejects.
674    pub fn archive_request(&self) -> ArchiveRequest {
675        ArchiveRequest::archive()
676    }
677
678    /// Go from a shown plan to the confirming state.
679    ///
680    /// Two steps rather than one, because "I saw it" and "I approved it" are two
681    /// different decisions and a single `y` conflates them. The confirming state
682    /// renders a different affordance, so the user can see which one they are in.
683    pub fn arm(&mut self) {
684        if let BulkState::Preview(plan) = &self.state {
685            self.state = BulkState::Confirming(plan.clone());
686        }
687    }
688
689    /// Whether the modal is armed — the state in which `y` writes.
690    pub fn is_armed(&self) -> bool {
691        matches!(self.state, BulkState::Confirming(_))
692    }
693
694    /// Confirm the plan that is on screen and apply it.
695    ///
696    /// Refuses while the plan is **stale**, and the refusal says why. That is
697    /// contract §4's "any change to selection or patch requires a new preview", and
698    /// it is checked here rather than inside the apply so a TUI user gets a TUI
699    /// message.
700    pub fn confirm(&mut self, store: &Store) -> Result<()> {
701        if self.plan_stale {
702            return Err(TixError::InvalidInput(
703                "the selection or the fields changed since the preview — press `p` \
704                 again to see what would happen now"
705                    .to_string(),
706            ));
707        }
708        let (BulkState::Confirming(plan) | BulkState::Preview(plan)) = &self.state else {
709            return Err(TixError::InvalidInput(
710                "nothing to confirm — press `p` to preview first".to_string(),
711            ));
712        };
713        let mut confirmed = (**plan).clone();
714        confirmed.confirmation = Some(Confirmation::issue(plan));
715        let text = match self.action {
716            BatchAction::Edit => {
717                let report =
718                    bulk::apply_fields(store, &confirmed, &crate::storage::actor_identity(), None)?;
719                let text = cli_bulk::render_report(&report, &confirmed, Mode::Apply);
720                self.state = BulkState::Done(Box::new(report), text.clone());
721                text
722            }
723            BatchAction::Link | BatchAction::Unlink => {
724                let text = self.apply_pairs(store, &confirmed);
725                self.state = BulkState::Refused(text.clone());
726                text
727            }
728            BatchAction::Archive => {
729                // The archive direction, through the **same** `apply_archive` the CLI
730                // and MCP use: the store's archive service renames each selected file
731                // in place and appends one History event, and the per-item outcomes are
732                // the shared vocabulary. A stale-plan check already ran above, and the
733                // confirmation is set for the same reason the edit branch sets it.
734                //
735                // The result is a `Done` report, exactly as an edit batch: the user
736                // pressed `y` to apply, so the honest answer is what each issue became,
737                // not a refusal screen.
738                let report = bulk::apply_archive(store, &confirmed)?;
739                let text = cli_bulk::render_report(&report, &confirmed, Mode::Apply);
740                self.state = BulkState::Done(Box::new(report), text.clone());
741                text
742            }
743        };
744        self.note = text.lines().next().map(str::to_string);
745        Ok(())
746    }
747
748    /// One plan and one apply per selected issue, for a pair.
749    ///
750    /// Per issue rather than one batched pair because `from` is a single issue in the
751    /// relationship vocabulary. Each issue goes through the same `build_plan` +
752    /// `apply_pair` the CLI uses, so a mixed result is a per-issue line and a
753    /// refusal names the issue it refused.
754    fn apply_pairs(&self, store: &Store, plan: &BatchPlan) -> String {
755        let mut lines = Vec::new();
756        let mut applied = 0usize;
757        let mut refused = 0usize;
758        for issue in plan.selection.issues() {
759            let numbers = [issue.number];
760            let outcome = match self.relationship_for(&numbers, None) {
761                Ok(op) => {
762                    let per_issue = bulk::plan::build_plan(
763                        &store.config,
764                        &store.issues_dir,
765                        Selection::from_issues(
766                            vec![issue.clone()],
767                            bulk::SelectionSource::Explicit,
768                            bulk::SelectionScope::Active,
769                        ),
770                        IssuePatch {
771                            relationship: Some(op.clone()),
772                            ..IssuePatch::default()
773                        },
774                        |o: &RelationshipOp| bulk::pair::plan_for(store, o),
775                    );
776                    match per_issue {
777                        Ok(mut per_issue) => {
778                            per_issue.confirmation = Some(Confirmation::issue(&per_issue));
779                            match bulk::apply_pair(store, &per_issue) {
780                                Ok(outcome) => {
781                                    if outcome.is_applied() {
782                                        applied += 1;
783                                    } else {
784                                        refused += 1;
785                                    }
786                                    outcome.as_line()
787                                }
788                                Err(e) => {
789                                    refused += 1;
790                                    format!("refused: {e}")
791                                }
792                            }
793                        }
794                        Err(e) => {
795                            refused += 1;
796                            format!("refused: {e}")
797                        }
798                    }
799                }
800                Err(e) => {
801                    refused += 1;
802                    format!("refused: {e}")
803                }
804            };
805            lines.push(format!("#{} {} — {outcome}", issue.number, issue.title));
806        }
807        lines.push(String::new());
808        lines.push(format!(
809            "{applied} relationship operation{} applied, {refused} refused",
810            if applied == 1 { "" } else { "s" }
811        ));
812        lines.join("\n")
813    }
814
815    /// Back to choosing, from any state.
816    ///
817    /// **`confirm` is the only method here that writes**, so cancelling from any other
818    /// state cannot have changed a file. The confirm state keeps its plan so a
819    /// mis-aimed `esc` does not throw away a preview the user wanted to look at
820    /// again.
821    pub fn cancel(&mut self) {
822        self.state = BulkState::Selecting;
823        self.focus = BulkFocus::Row;
824        self.draft.clear();
825        self.plan_stale = true;
826    }
827
828    /// The state, in the words a user reads.
829    pub fn state_label(&self) -> &'static str {
830        match self.state {
831            BulkState::Selecting => "choosing",
832            BulkState::Preview(_) => "preview — nothing written",
833            BulkState::Confirming(_) => "confirming — y writes",
834            BulkState::Done(..) => "done",
835            BulkState::Refused(_) => "refused",
836            BulkState::NeedsInput(_) => "needs input",
837        }
838    }
839
840    /// The rows, as the modal shows them: a cursor, a name and a value.
841    pub fn rows(&self) -> Vec<(bool, String, String)> {
842        self.row_names()
843            .into_iter()
844            .enumerate()
845            .map(|(index, name)| {
846                let value = if self.focus == BulkFocus::Edit && index == self.cursor {
847                    format!("{}█", self.draft)
848                } else {
849                    self.row_value(index)
850                };
851                (index == self.cursor, name.to_string(), value)
852            })
853            .collect()
854    }
855
856    /// The footer, naming the keys that mean something **right now**.
857    ///
858    /// The list is per-focus and per-state, because a footer that names `y` while `y`
859    /// types a letter is a footer that teaches the wrong thing.
860    pub fn footer(&self) -> String {
861        if self.focus == BulkFocus::Edit {
862            return "enter commit · esc revert".to_string();
863        }
864        match (&self.state, self.action) {
865            (BulkState::Preview(_), _) => {
866                "y to confirm · p to re-preview · esc to cancel".to_string()
867            }
868            (BulkState::Confirming(_), _) => "y APPLIES THIS WRITE · esc to cancel".to_string(),
869            (_, BatchAction::Edit) => {
870                "space mark · x unmark · X clear · c clear field · enter edit · a action · p preview · esc cancel"
871                    .to_string()
872            }
873            _ => "enter edit · a action · p preview · esc cancel".to_string(),
874        }
875    }
876
877    /// The text a surface shows for the current state, from the **CLI's** renderers.
878    pub fn body(&self) -> String {
879        match &self.state {
880            BulkState::Selecting => {
881                let mut out = self.selection.summary();
882                let rows = self.rows();
883                if rows.is_empty() {
884                    out.push_str(&format!(
885                        "\nno fields to fill in for {} — the selection is the whole request\n",
886                        self.action.as_str()
887                    ));
888                } else {
889                    out.push_str("\nfields (enter to edit):\n");
890                    for (on, name, value) in rows {
891                        let cursor = if on { ">" } else { " " };
892                        out.push_str(&format!("{cursor} {name}: {value}\n"));
893                    }
894                }
895                if let Some(note) = &self.note {
896                    out.push_str(&format!("\nnote: {note}\n"));
897                }
898                out.push_str(&format!("\n{}", self.footer()));
899                out
900            }
901            BulkState::Preview(plan) => {
902                let mut out = cli_bulk::render_plan(plan, Mode::Preview);
903                if let Some(note) = &self.note {
904                    out.push_str(&format!("\nnote: {note}"));
905                }
906                out.push('\n');
907                out.push_str(&self.footer());
908                out
909            }
910            BulkState::Confirming(plan) => {
911                let mut out = cli_bulk::render_plan(plan, Mode::Preview);
912                out.push('\n');
913                out.push_str(&self.footer());
914                out
915            }
916            BulkState::Done(_, text) | BulkState::Refused(text) => text.clone(),
917            BulkState::NeedsInput(what) => format!("{what}\n\n{}", self.footer()),
918        }
919    }
920}
921
922/// Render the modal. Never asserts on pixels — a test reads the buffer.
923pub fn render(frame: &mut Frame, area: Rect, modal: &BulkModal) {
924    let rect = crate::ui::modal::modal_rect(area);
925    frame.render_widget(Clear, rect);
926    let block = Block::default()
927        .borders(Borders::ALL)
928        .title(format!(" batch {} ", modal.action.as_str()))
929        .border_style(Style::default().fg(ratatui::style::Color::Cyan));
930    let inner = block.inner(rect);
931    frame.render_widget(block, rect);
932
933    let chunks = Layout::default()
934        .direction(Direction::Vertical)
935        .constraints([Constraint::Length(3), Constraint::Min(1)])
936        .split(inner);
937    let header = format!("{}  [{}]", modal.selection.summary(), modal.state_label());
938    frame.render_widget(
939        Paragraph::new(Line::from(Span::styled(
940            header,
941            Style::default().add_modifier(Modifier::BOLD),
942        ))),
943        chunks[0],
944    );
945    frame.render_widget(
946        Paragraph::new(modal.body()).block(Block::default().borders(Borders::TOP)),
947        chunks[1],
948    );
949}
950
951/// The issues a board shows, as `(number, Issue)`, in the order the cards are drawn.
952///
953/// A function over the board rather than a `store.list()`, because "visible" is the
954/// whole point: with a filter applied, `store.list()` returns issues the user cannot
955/// see, and marking those would produce a plan for cards that are not on screen.
956pub fn visible_issues(
957    board: &crate::ui::board::BoardState,
958    store: &Store,
959) -> Result<Vec<(u32, crate::model::Issue)>> {
960    let mut out = Vec::new();
961    let mut numbers = Vec::new();
962    for index in 0..=board.columns.len() {
963        for card in board.column_cards(index) {
964            numbers.push(card.number);
965        }
966    }
967    for issue in store.list()? {
968        if numbers.contains(&issue.number) {
969            out.push((issue.number, issue));
970        }
971    }
972    // Board order, not store order: the plan reads in the order the cards do.
973    Ok(numbers
974        .into_iter()
975        .filter_map(|number| {
976            out.iter()
977                .find(|(n, _)| *n == number)
978                .map(|(n, issue)| (*n, issue.clone()))
979        })
980        .collect())
981}
982
983/// The selection as a list, for the board's own "N selected" popover.
984pub fn selection_items(
985    selection: &BulkSelection,
986    issues: &[(u32, crate::model::Issue)],
987) -> Vec<ListItem<'static>> {
988    selection
989        .numbers()
990        .iter()
991        .map(|number| {
992            let title = issues
993                .iter()
994                .find(|(n, _)| n == number)
995                .map(|(_, i)| i.title.clone())
996                .unwrap_or_else(|| "(not on the board)".to_string());
997            ListItem::new(format!("#{number} {title}"))
998        })
999        .collect()
1000}
1001
1002/// Render the selection list, for a caller that wants it on screen.
1003pub fn render_selection_list(
1004    frame: &mut Frame,
1005    area: Rect,
1006    selection: &BulkSelection,
1007    issues: &[(u32, crate::model::Issue)],
1008) {
1009    let list = List::new(selection_items(selection, issues)).block(
1010        Block::default()
1011            .borders(Borders::ALL)
1012            .title(" batch selection "),
1013    );
1014    frame.render_widget(list, area);
1015}