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}