use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph};
use crate::bulk::plan::BatchPlan;
use crate::bulk::{
self, ApplyReport, ArchiveRequest, Confirmation, IssuePatch, RelationshipOp, Selection,
};
use crate::commands::bulk::{self as cli_bulk, Mode, PatchFlags};
use crate::error::{Result, TixError};
use crate::storage::Store;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BatchAction {
Edit,
Link,
Unlink,
Archive,
}
impl BatchAction {
pub fn as_str(self) -> &'static str {
match self {
Self::Edit => "edit",
Self::Link => "link",
Self::Unlink => "unlink",
Self::Archive => "archive",
}
}
pub fn takes_fields(self) -> bool {
matches!(self, Self::Edit)
}
pub fn is_pair(self) -> bool {
matches!(self, Self::Link | Self::Unlink)
}
pub fn next(self) -> Self {
match self {
Self::Edit => Self::Link,
Self::Link => Self::Unlink,
Self::Unlink => Self::Archive,
Self::Archive => Self::Edit,
}
}
}
impl From<&crate::cli::BulkAction> for BatchAction {
fn from(action: &crate::cli::BulkAction) -> Self {
match action {
crate::cli::BulkAction::Edit { .. } => Self::Edit,
crate::cli::BulkAction::Link { .. } => Self::Link,
crate::cli::BulkAction::Unlink { .. } => Self::Unlink,
crate::cli::BulkAction::Archive { .. } => Self::Archive,
}
}
}
pub const MAX_BATCH_SELECTION: usize = 200;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SelectionProblem {
TooLarge,
AlreadySelected,
}
impl SelectionProblem {
pub fn as_str(self) -> &'static str {
match self {
Self::TooLarge => "selection_full",
Self::AlreadySelected => "already_selected",
}
}
}
impl std::fmt::Display for SelectionProblem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::TooLarge => write!(
f,
"a batch selection holds at most {MAX_BATCH_SELECTION} issues — \
clear some first, or narrow the filter"
),
Self::AlreadySelected => {
write!(f, "that issue is already selected — press `x` to remove it")
}
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct BulkSelection {
numbers: Vec<u32>,
}
impl BulkSelection {
pub fn new() -> Self {
Self::default()
}
pub fn toggle(&mut self, number: u32) -> std::result::Result<bool, SelectionProblem> {
if self.numbers.contains(&number) {
return Err(SelectionProblem::AlreadySelected);
}
if self.numbers.len() >= MAX_BATCH_SELECTION {
return Err(SelectionProblem::TooLarge);
}
self.numbers.push(number);
Ok(true)
}
pub fn remove(&mut self, number: u32) -> bool {
let before = self.numbers.len();
self.numbers.retain(|n| *n != number);
self.numbers.len() != before
}
pub fn clear(&mut self) {
self.numbers.clear();
}
pub fn contains(&self, number: u32) -> bool {
self.numbers.contains(&number)
}
pub fn numbers(&self) -> &[u32] {
&self.numbers
}
pub fn len(&self) -> usize {
self.numbers.len()
}
pub fn is_empty(&self) -> bool {
self.numbers.is_empty()
}
pub fn summary(&self) -> String {
if self.numbers.is_empty() {
return "no issues selected — press `space` on a card, `X` to clear".to_string();
}
let list: Vec<String> = self.numbers.iter().map(u32::to_string).collect();
format!(
"{} selected (#{}) of at most {MAX_BATCH_SELECTION}",
self.numbers.len(),
list.join(", ")
)
}
pub fn to_selection(&self, issues: &[(u32, crate::model::Issue)]) -> (Selection, Vec<u32>) {
let mut kept = Vec::new();
let mut dropped = Vec::new();
for number in &self.numbers {
if issues.iter().any(|(n, _)| n == number) {
kept.push(*number);
} else {
dropped.push(*number);
}
}
let selection = Selection::from_issues(
kept.iter().filter_map(|number| {
issues
.iter()
.find(|(n, _)| n == number)
.map(|(_, issue)| issue.clone())
}),
bulk::SelectionSource::Explicit,
bulk::SelectionScope::Active,
);
(selection, dropped)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BulkState {
Selecting,
Preview(Box<BatchPlan>),
Confirming(Box<BatchPlan>),
Done(Box<ApplyReport>, String),
Refused(String),
NeedsInput(&'static str),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BulkFocus {
Row,
Edit,
}
#[derive(Debug, Clone)]
pub struct BulkModal {
pub action: BatchAction,
pub flags: PatchFlags,
pub target: Option<String>,
pub kind: Option<String>,
pub cursor: usize,
pub focus: BulkFocus,
pub draft: String,
pub selection: BulkSelection,
pub state: BulkState,
pub note: Option<String>,
pub plan_stale: bool,
}
impl BulkModal {
pub fn new(action: BatchAction) -> Self {
Self {
action,
flags: PatchFlags::default(),
target: None,
kind: None,
cursor: 0,
focus: BulkFocus::Row,
draft: String::new(),
selection: BulkSelection::new(),
state: BulkState::Selecting,
note: None,
plan_stale: true,
}
}
pub fn row_names(&self) -> Vec<&'static str> {
let mut names: Vec<&'static str> = if self.action.takes_fields() {
PatchFlags::field_rows().to_vec()
} else {
Vec::new()
};
if self.action.is_pair() {
names.push("target");
names.push("kind");
}
names
}
pub fn row_name(&self, index: usize) -> &'static str {
self.row_names()
.into_iter()
.nth(index)
.unwrap_or("selection")
}
pub fn row_value(&self, index: usize) -> String {
let flags = &self.flags;
match self.row_name(index) {
"status" => flags.status.clone(),
"priority" => flags.priority.clone(),
"assignee" => flags.assignee.clone(),
"labels" => flags.labels.clone(),
"start-date" => flags.start_date.clone(),
"due-date" => flags.due_date.clone(),
"estimate" => flags.estimate.clone(),
"cycles" => flags.cycles.clone(),
"milestones" => flags.milestones.clone(),
"clear" => (!flags.clear.is_empty()).then(|| flags.clear.join(" ")),
"target" => self.target.clone(),
"kind" => self.kind.clone(),
_ => None,
}
.unwrap_or_else(|| "—".to_string())
}
pub fn set_row(&mut self, index: usize, value: String) -> Result<()> {
let value = value.trim().to_string();
let name = self.row_name(index);
let flags = &mut self.flags;
let slot = match name {
"status" => &mut flags.status,
"priority" => &mut flags.priority,
"assignee" => &mut flags.assignee,
"labels" => &mut flags.labels,
"start-date" => &mut flags.start_date,
"due-date" => &mut flags.due_date,
"estimate" => &mut flags.estimate,
"cycles" => &mut flags.cycles,
"milestones" => &mut flags.milestones,
"clear" => {
if value.is_empty() {
flags.clear.clear();
self.mark_stale();
return Ok(());
}
if !PatchFlags::is_clearable(&value) {
return Err(TixError::InvalidInput(format!(
"cannot clear '{value}' — clearable fields: {}",
PatchFlags::clearable().join(", ")
)));
}
if !flags.clear.is_empty() && !flags.clear.contains(&value) {
return Err(TixError::InvalidInput(format!(
"one batch clears one field — '{value}' is clearable, and '{}' is \
already named; clear them one run at a time",
flags.clear[0]
)));
}
flags.clear = vec![value];
self.mark_stale();
return Ok(());
}
"target" => {
let slot = &mut self.target;
*slot = (!value.is_empty()).then_some(value);
self.mark_stale();
return Ok(());
}
"kind" => {
let slot = &mut self.kind;
*slot = (!value.is_empty()).then_some(value);
self.mark_stale();
return Ok(());
}
_ => return Ok(()),
};
*slot = (!value.is_empty()).then_some(value);
self.mark_stale();
Ok(())
}
pub fn mark_stale(&mut self) {
self.plan_stale = true;
}
pub fn begin_edit(&mut self) {
let row = self.cursor;
self.draft = self.row_value(row);
if self.draft == "—" {
self.draft.clear();
}
self.focus = BulkFocus::Edit;
}
pub fn commit_edit(&mut self) -> Result<()> {
let row = self.cursor;
let draft = std::mem::take(&mut self.draft);
self.focus = BulkFocus::Row;
self.set_row(row, draft)
}
pub fn abort_edit(&mut self) {
self.draft.clear();
self.focus = BulkFocus::Row;
}
pub fn move_cursor(&mut self, delta: isize) {
let count = self.row_names().len();
if count == 0 {
self.cursor = 0;
return;
}
let len = count as isize;
let next = (self.cursor as isize + delta).rem_euclid(len);
self.cursor = next as usize;
}
pub fn patch(&self) -> Result<IssuePatch> {
self.flags.to_patch()
}
pub fn patch_for(&self) -> Result<IssuePatch> {
Ok(match self.action {
BatchAction::Edit => self.patch()?,
BatchAction::Link | BatchAction::Unlink => IssuePatch {
relationship: Some(self.relationship_for(&[], None)?),
..IssuePatch::default()
},
BatchAction::Archive => IssuePatch {
archive: Some(self.archive_request()),
..IssuePatch::default()
},
})
}
pub fn preview(&mut self, store: &Store, issues: &[(u32, crate::model::Issue)]) -> Result<()> {
if self.selection.is_empty() {
self.state = BulkState::NeedsInput("select at least one issue with `space`");
return Ok(());
}
let patch = match self.patch_for() {
Ok(patch) => patch,
Err(e) => {
self.state = BulkState::Refused(e.to_string());
self.note = Some(e.to_string());
return Ok(());
}
};
let (selection, dropped) = self.selection.to_selection(issues);
if selection.is_empty() {
self.state = BulkState::NeedsInput("none of the selected issues are on the board");
return Ok(());
}
let plan = match bulk::plan::build_plan(
&store.config,
&store.issues_dir,
selection,
patch,
|op: &RelationshipOp| bulk::pair::plan_for(store, op),
)
.map(|mut plan| {
crate::wip::bulk::annotate_plan(
&mut plan,
&store.config,
&store.list().unwrap_or_default(),
);
plan
}) {
Ok(plan) => plan,
Err(e) => {
self.state = BulkState::Refused(e.to_string());
self.note = Some(e.to_string());
return Ok(());
}
};
self.note = (!dropped.is_empty()).then(|| {
format!(
"{} selected issue(s) are no longer on the board and were left out: #{}",
dropped.len(),
dropped
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join(", #")
)
});
self.state = BulkState::Preview(Box::new(plan));
self.plan_stale = false;
Ok(())
}
pub fn relationship_for(
&self,
numbers: &[u32],
kind_override: Option<String>,
) -> Result<RelationshipOp> {
let target = self
.target
.as_deref()
.map(str::trim)
.filter(|t| !t.is_empty())
.ok_or_else(|| {
TixError::InvalidInput("a pair needs a target — edit the `target` row".to_string())
})?;
let kind = match kind_override {
Some(raw) => crate::commands::link::require_kind(Some(raw.as_str()))?,
None => crate::commands::link::require_kind(self.kind.as_deref())?,
};
let from = numbers.first().map(u32::to_string).unwrap_or_default();
Ok(if self.action == BatchAction::Unlink {
RelationshipOp::unlink(from, target, kind)
} else {
RelationshipOp::link(from, target, kind)
})
}
pub fn archive_request(&self) -> ArchiveRequest {
ArchiveRequest::archive()
}
pub fn arm(&mut self) {
if let BulkState::Preview(plan) = &self.state {
self.state = BulkState::Confirming(plan.clone());
}
}
pub fn is_armed(&self) -> bool {
matches!(self.state, BulkState::Confirming(_))
}
pub fn confirm(&mut self, store: &Store) -> Result<()> {
if self.plan_stale {
return Err(TixError::InvalidInput(
"the selection or the fields changed since the preview — press `p` \
again to see what would happen now"
.to_string(),
));
}
let (BulkState::Confirming(plan) | BulkState::Preview(plan)) = &self.state else {
return Err(TixError::InvalidInput(
"nothing to confirm — press `p` to preview first".to_string(),
));
};
let mut confirmed = (**plan).clone();
confirmed.confirmation = Some(Confirmation::issue(plan));
let text = match self.action {
BatchAction::Edit => {
let report =
bulk::apply_fields(store, &confirmed, &crate::storage::actor_identity(), None)?;
let text = cli_bulk::render_report(&report, &confirmed, Mode::Apply);
self.state = BulkState::Done(Box::new(report), text.clone());
text
}
BatchAction::Link | BatchAction::Unlink => {
let text = self.apply_pairs(store, &confirmed);
self.state = BulkState::Refused(text.clone());
text
}
BatchAction::Archive => {
let report = bulk::apply_archive(store, &confirmed)?;
let text = cli_bulk::render_report(&report, &confirmed, Mode::Apply);
self.state = BulkState::Done(Box::new(report), text.clone());
text
}
};
self.note = text.lines().next().map(str::to_string);
Ok(())
}
fn apply_pairs(&self, store: &Store, plan: &BatchPlan) -> String {
let mut lines = Vec::new();
let mut applied = 0usize;
let mut refused = 0usize;
for issue in plan.selection.issues() {
let numbers = [issue.number];
let outcome = match self.relationship_for(&numbers, None) {
Ok(op) => {
let per_issue = bulk::plan::build_plan(
&store.config,
&store.issues_dir,
Selection::from_issues(
vec![issue.clone()],
bulk::SelectionSource::Explicit,
bulk::SelectionScope::Active,
),
IssuePatch {
relationship: Some(op.clone()),
..IssuePatch::default()
},
|o: &RelationshipOp| bulk::pair::plan_for(store, o),
);
match per_issue {
Ok(mut per_issue) => {
per_issue.confirmation = Some(Confirmation::issue(&per_issue));
match bulk::apply_pair(store, &per_issue) {
Ok(outcome) => {
if outcome.is_applied() {
applied += 1;
} else {
refused += 1;
}
outcome.as_line()
}
Err(e) => {
refused += 1;
format!("refused: {e}")
}
}
}
Err(e) => {
refused += 1;
format!("refused: {e}")
}
}
}
Err(e) => {
refused += 1;
format!("refused: {e}")
}
};
lines.push(format!("#{} {} — {outcome}", issue.number, issue.title));
}
lines.push(String::new());
lines.push(format!(
"{applied} relationship operation{} applied, {refused} refused",
if applied == 1 { "" } else { "s" }
));
lines.join("\n")
}
pub fn cancel(&mut self) {
self.state = BulkState::Selecting;
self.focus = BulkFocus::Row;
self.draft.clear();
self.plan_stale = true;
}
pub fn state_label(&self) -> &'static str {
match self.state {
BulkState::Selecting => "choosing",
BulkState::Preview(_) => "preview — nothing written",
BulkState::Confirming(_) => "confirming — y writes",
BulkState::Done(..) => "done",
BulkState::Refused(_) => "refused",
BulkState::NeedsInput(_) => "needs input",
}
}
pub fn rows(&self) -> Vec<(bool, String, String)> {
self.row_names()
.into_iter()
.enumerate()
.map(|(index, name)| {
let value = if self.focus == BulkFocus::Edit && index == self.cursor {
format!("{}█", self.draft)
} else {
self.row_value(index)
};
(index == self.cursor, name.to_string(), value)
})
.collect()
}
pub fn footer(&self) -> String {
if self.focus == BulkFocus::Edit {
return "enter commit · esc revert".to_string();
}
match (&self.state, self.action) {
(BulkState::Preview(_), _) => {
"y to confirm · p to re-preview · esc to cancel".to_string()
}
(BulkState::Confirming(_), _) => "y APPLIES THIS WRITE · esc to cancel".to_string(),
(_, BatchAction::Edit) => {
"space mark · x unmark · X clear · c clear field · enter edit · a action · p preview · esc cancel"
.to_string()
}
_ => "enter edit · a action · p preview · esc cancel".to_string(),
}
}
pub fn body(&self) -> String {
match &self.state {
BulkState::Selecting => {
let mut out = self.selection.summary();
let rows = self.rows();
if rows.is_empty() {
out.push_str(&format!(
"\nno fields to fill in for {} — the selection is the whole request\n",
self.action.as_str()
));
} else {
out.push_str("\nfields (enter to edit):\n");
for (on, name, value) in rows {
let cursor = if on { ">" } else { " " };
out.push_str(&format!("{cursor} {name}: {value}\n"));
}
}
if let Some(note) = &self.note {
out.push_str(&format!("\nnote: {note}\n"));
}
out.push_str(&format!("\n{}", self.footer()));
out
}
BulkState::Preview(plan) => {
let mut out = cli_bulk::render_plan(plan, Mode::Preview);
if let Some(note) = &self.note {
out.push_str(&format!("\nnote: {note}"));
}
out.push('\n');
out.push_str(&self.footer());
out
}
BulkState::Confirming(plan) => {
let mut out = cli_bulk::render_plan(plan, Mode::Preview);
out.push('\n');
out.push_str(&self.footer());
out
}
BulkState::Done(_, text) | BulkState::Refused(text) => text.clone(),
BulkState::NeedsInput(what) => format!("{what}\n\n{}", self.footer()),
}
}
}
pub fn render(frame: &mut Frame, area: Rect, modal: &BulkModal) {
let rect = crate::ui::modal::modal_rect(area);
frame.render_widget(Clear, rect);
let block = Block::default()
.borders(Borders::ALL)
.title(format!(" batch {} ", modal.action.as_str()))
.border_style(Style::default().fg(ratatui::style::Color::Cyan));
let inner = block.inner(rect);
frame.render_widget(block, rect);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(1)])
.split(inner);
let header = format!("{} [{}]", modal.selection.summary(), modal.state_label());
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
header,
Style::default().add_modifier(Modifier::BOLD),
))),
chunks[0],
);
frame.render_widget(
Paragraph::new(modal.body()).block(Block::default().borders(Borders::TOP)),
chunks[1],
);
}
pub fn visible_issues(
board: &crate::ui::board::BoardState,
store: &Store,
) -> Result<Vec<(u32, crate::model::Issue)>> {
let mut out = Vec::new();
let mut numbers = Vec::new();
for index in 0..=board.columns.len() {
for card in board.column_cards(index) {
numbers.push(card.number);
}
}
for issue in store.list()? {
if numbers.contains(&issue.number) {
out.push((issue.number, issue));
}
}
Ok(numbers
.into_iter()
.filter_map(|number| {
out.iter()
.find(|(n, _)| *n == number)
.map(|(n, issue)| (*n, issue.clone()))
})
.collect())
}
pub fn selection_items(
selection: &BulkSelection,
issues: &[(u32, crate::model::Issue)],
) -> Vec<ListItem<'static>> {
selection
.numbers()
.iter()
.map(|number| {
let title = issues
.iter()
.find(|(n, _)| n == number)
.map(|(_, i)| i.title.clone())
.unwrap_or_else(|| "(not on the board)".to_string());
ListItem::new(format!("#{number} {title}"))
})
.collect()
}
pub fn render_selection_list(
frame: &mut Frame,
area: Rect,
selection: &BulkSelection,
issues: &[(u32, crate::model::Issue)],
) {
let list = List::new(selection_items(selection, issues)).block(
Block::default()
.borders(Borders::ALL)
.title(" batch selection "),
);
frame.render_widget(list, area);
}