#[allow(clippy::wildcard_imports)]
use super::*;
impl App {
pub(in crate::app) fn open_action_menu(
&mut self,
hit: Option<HitTarget>,
pointer: bool,
) -> Result<Action> {
let target = match hit {
Some(target @ HitTarget::Card { .. }) => target,
_ => return Ok(Action::None),
};
self.select_at(target);
if pointer {
self.mouse.last_down = None;
}
let items = self.issue_action_items(pointer)?;
if items.is_empty() {
return Ok(Action::None);
}
self.action_menu = Some(ActionMenu::new(items));
self.mode = Mode::ActionMenu {
return_to: Box::new(Mode::Board),
};
Ok(Action::None)
}
pub(in crate::app) fn action_shortcut(
&self,
context: InputContext,
action: TuiAction,
) -> Option<String> {
let label = self.bindings.label(context, action);
(label != "—").then_some(label)
}
#[allow(clippy::too_many_lines)]
pub(in crate::app) fn issue_action_items(&self, pointer: bool) -> Result<Vec<ActionMenuItem>> {
let Some(card) = self.board.get_selected_card().cloned() else {
return Ok(Vec::new());
};
let issue = self
.store
.get(IssueSelector::Filename(card.filename.clone()))?;
let current_column = self.board.active_column_name().to_string();
let board_columns = self.active_board_config().columns.clone();
let relationships = self
.store
.relationships_for(issue.number)
.unwrap_or_default();
let pending_mentions = self
.mention_store()
.map(|store| store.pending_for_issue(&self.mention_candidates, issue.number))
.unwrap_or_default();
let marked = self
.bulk
.as_ref()
.is_some_and(|modal| modal.selection.contains(issue.number));
let triage_entry = self
.store
.config
.triage
.as_ref()
.and_then(|triage| triage.entry_status.as_deref())
.is_some_and(|entry| issue.status.to_string() == entry);
let mut items = Vec::new();
for column in &board_columns {
if *column == current_column {
continue;
}
let hidden = self
.board
.hidden
.iter()
.any(|configured| configured.name == *column);
let label = if hidden {
format!("Move to {column} (hidden)")
} else {
format!("Move to {column}")
};
items.push(ActionMenuItem::new(
IssueAction::MoveTo {
column: column.clone(),
},
label,
ActionGroup::Move,
));
}
items.push(
ActionMenuItem::new(IssueAction::OpenDetail, "Open detail", ActionGroup::Open)
.shortcut(
self.action_shortcut(InputContext::Board, TuiAction::Open)
.unwrap_or_else(|| "enter".to_string()),
),
);
items.push(
ActionMenuItem::new(
IssueAction::EditInEditor,
"Edit in editor",
ActionGroup::Open,
)
.shortcut(
self.action_shortcut(InputContext::Board, TuiAction::Edit)
.unwrap_or_else(|| "e".to_string()),
),
);
let view_label = match crate::editor::resolve_viewer(self.store.config.viewer.as_deref()) {
Ok(viewer) if viewer == "mdterm" => "View issue (in mdterm)",
_ => "View issue",
};
items.push(
ActionMenuItem::new(IssueAction::ViewIssue, view_label, ActionGroup::Open).shortcut(
self.action_shortcut(InputContext::Board, TuiAction::View)
.unwrap_or_else(|| "V".to_string()),
),
);
items.push(
ActionMenuItem::new(IssueAction::OpenAgent, "Open agent", ActionGroup::Open).shortcut(
self.action_shortcut(InputContext::Board, TuiAction::Agent)
.unwrap_or_else(|| "A".to_string()),
),
);
items.push(
ActionMenuItem::new(
IssueAction::CloneIssue,
"Clone into new issue",
ActionGroup::Open,
)
.shortcut(
self.action_shortcut(InputContext::Board, TuiAction::CloneIssue)
.unwrap_or_else(|| "C".to_string()),
),
);
if !pending_mentions.is_empty() {
items.push(ActionMenuItem::new(
IssueAction::OpenMentions,
"Open mentions…",
ActionGroup::Open,
));
}
if !issue.related_issues.is_empty() {
items.push(
ActionMenuItem::new(
IssueAction::RelatedPicker,
"Related issues…",
ActionGroup::Open,
)
.shortcut(
self.action_shortcut(InputContext::Detail, TuiAction::RelatedPicker)
.unwrap_or_default(),
),
);
items.push(
ActionMenuItem::new(
IssueAction::FollowRelated,
"Follow related",
ActionGroup::Open,
)
.shortcut(
self.action_shortcut(InputContext::Detail, TuiAction::FollowRelated)
.unwrap_or_default(),
),
);
}
items.push(
ActionMenuItem::new(IssueAction::Comment, "Comment", ActionGroup::Content).shortcut(
self.action_shortcut(InputContext::Detail, TuiAction::Comment)
.unwrap_or_default(),
),
);
items.push(
ActionMenuItem::new(
IssueAction::AttachAsset,
"Attach asset",
ActionGroup::Content,
)
.shortcut(
self.action_shortcut(InputContext::Detail, TuiAction::Asset)
.unwrap_or_default(),
),
);
items.push(
ActionMenuItem::new(
IssueAction::NewRelationship,
"New relationship",
ActionGroup::Content,
)
.shortcut(
self.action_shortcut(InputContext::Detail, TuiAction::NewRelationship)
.unwrap_or_default(),
),
);
if !relationships.is_empty() {
items.push(ActionMenuItem::new(
IssueAction::Relationships,
"Relationships…",
ActionGroup::Content,
));
}
items.push(
ActionMenuItem::new(
IssueAction::SetParent,
"Set / change / clear parent",
ActionGroup::Content,
)
.shortcut(
self.action_shortcut(InputContext::Detail, TuiAction::EditParent)
.unwrap_or_default(),
),
);
items.push(
ActionMenuItem::new(
IssueAction::RecordHealth,
"Record health",
ActionGroup::Content,
)
.shortcut(
self.action_shortcut(InputContext::Detail, TuiAction::HealthForm)
.unwrap_or_default(),
),
);
items.push(ActionMenuItem::new(
IssueAction::AddTimeEntry,
"Add time entry",
ActionGroup::Content,
));
items.push(
ActionMenuItem::new(IssueAction::Delete, "Delete", ActionGroup::Destructive)
.shortcut(
self.action_shortcut(InputContext::Board, TuiAction::Delete)
.unwrap_or_else(|| "d".to_string()),
)
.danger(true),
);
if card.archived {
items.push(
ActionMenuItem::new(
IssueAction::Restore,
"Restore issue",
ActionGroup::Destructive,
)
.danger(true),
);
} else {
items.push(
ActionMenuItem::new(
IssueAction::Archive,
"Archive issue",
ActionGroup::Destructive,
)
.danger(true),
);
}
items.push(
ActionMenuItem::new(
IssueAction::Mark { on: !marked },
if marked {
"Unmark for batch"
} else {
"Mark for batch"
},
ActionGroup::Marks,
)
.shortcut(
self.action_shortcut(
InputContext::Board,
if marked {
TuiAction::Unmark
} else {
TuiAction::Mark
},
)
.unwrap_or_default(),
),
);
if let Some(modal) = self.bulk.as_ref() {
items.push(
ActionMenuItem::new(
IssueAction::Bulk {
action: modal.action,
},
format!("Batch {}…", modal.action.as_str()),
ActionGroup::Marks,
)
.shortcut(
self.action_shortcut(InputContext::Board, TuiAction::BulkModal)
.unwrap_or_default(),
),
);
items.push(ActionMenuItem::new(
IssueAction::ClearMarks,
"Clear all marks",
ActionGroup::Marks,
));
}
if triage_entry {
for (decision, label) in [
(crate::triage::DecisionKind::Accept, "Triage: accept"),
(crate::triage::DecisionKind::Decline, "Triage: decline"),
(crate::triage::DecisionKind::Duplicate, "Triage: duplicate"),
(crate::triage::DecisionKind::Defer, "Triage: defer"),
] {
items.push(ActionMenuItem::new(
IssueAction::Triage { decision },
label,
ActionGroup::Triage,
));
}
}
if pointer {
items.push(
ActionMenuItem::new(
IssueAction::RefreshBoard,
"Refresh board",
ActionGroup::Board,
)
.shortcut(
self.action_shortcut(InputContext::Board, TuiAction::Refresh)
.unwrap_or_else(|| "r".to_string()),
),
);
}
Ok(items)
}
pub(in crate::app) fn close_action_menu(&mut self) {
if matches!(self.mode, Mode::ActionMenu { .. }) {
if let Mode::ActionMenu { return_to } = std::mem::replace(&mut self.mode, Mode::Board) {
self.mode = *return_to;
}
}
self.action_menu = None;
}
pub(in crate::app) fn handle_action_menu_key(&mut self, key: KeyEvent) -> Result<Action> {
let event = {
let Some(menu) = self.action_menu.as_mut() else {
self.close_action_menu();
return Ok(Action::None);
};
menu.handle_key(key)
};
match event {
MenuEvent::None => Ok(Action::None),
MenuEvent::Dismissed => {
self.close_action_menu();
Ok(Action::None)
}
MenuEvent::Selected(index) => {
let action = self
.action_menu
.as_ref()
.and_then(|menu| menu.item(index))
.map(|item| item.action.clone());
let Some(action) = action else {
return Ok(Action::None);
};
if action == IssueAction::Relationships {
return self.push_relationship_submenu();
}
self.close_action_menu();
self.run_issue_action(action)
}
}
}
pub(in crate::app) fn handle_action_menu_mouse(&mut self, mouse: MouseEvent) -> Result<Action> {
match mouse.kind {
MouseEventKind::ScrollUp => {
if let Some(menu) = self.action_menu.as_mut() {
let _ = menu.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
}
Ok(Action::None)
}
MouseEventKind::ScrollDown => {
if let Some(menu) = self.action_menu.as_mut() {
let _ = menu.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
}
Ok(Action::None)
}
MouseEventKind::Down(_) => {
if let Some(down) = self.mouse.last_down.as_ref()
&& down.at.elapsed() < self.mouse.double_click_window
&& let Some(HitTarget::Card { col, row }) =
self.board_hit_at(mouse.column, mouse.row)
&& down.col == col
&& down.card == Some(row)
{
self.mouse.last_down = None;
self.close_action_menu();
return self.open_detail_for(HitTarget::Card { col, row });
}
self.mouse.last_down = None;
let Some(menu) = self.action_menu.as_ref() else {
self.close_action_menu();
return Ok(Action::None);
};
let area = self.frame_size.get();
let bounds = menu.bounds(area);
if !bounds.contains(Position::new(mouse.column, mouse.row)) {
self.close_action_menu();
return Ok(Action::None);
}
let Some(index) = menu.hit(area, mouse.column, mouse.row) else {
return Ok(Action::None);
};
let action = menu.item(index).map(|item| item.action.clone());
let Some(action) = action else {
return Ok(Action::None);
};
if action == IssueAction::Relationships {
return self.push_relationship_submenu();
}
self.close_action_menu();
self.run_issue_action(action)
}
_ => Ok(Action::None),
}
}
pub(in crate::app) fn push_relationship_submenu(&mut self) -> Result<Action> {
let Some(card) = self.board.get_selected_card().cloned() else {
return Ok(Action::None);
};
let relationships = self.store.relationships_for(card.number)?;
if relationships.is_empty() {
self.status_msg = Some("no relationships on this issue".to_string());
return Ok(Action::None);
}
let mut items = Vec::new();
for relationship in &relationships {
let line = crate::commands::link::relationship_line(relationship);
items.push(ActionMenuItem::new(
IssueAction::EditRelationship {
id: relationship.id.clone(),
},
format!("Edit {line}"),
ActionGroup::Content,
));
items.push(
ActionMenuItem::new(
IssueAction::RemoveRelationship {
id: relationship.id.clone(),
},
format!("Remove {line}"),
ActionGroup::Content,
)
.danger(true),
);
}
if let Some(menu) = self.action_menu.as_mut() {
menu.push("Relationships", items);
}
Ok(Action::None)
}
#[allow(clippy::too_many_lines)]
pub(in crate::app) fn run_issue_action(&mut self, action: IssueAction) -> Result<Action> {
let card = self.board.get_selected_card().cloned();
let Some(card) = card else {
return Ok(Action::None);
};
let bulk_targets = self.bulk_targets_for(card.number);
let mut open_detail = || -> Result<()> {
let issue = self
.store
.get(IssueSelector::Filename(card.filename.clone()))?;
self.open_detail_root(issue)
};
match action {
IssueAction::OpenDetail => self.open_detail_for(HitTarget::Card {
col: self.board.active_col,
row: self.board.selected,
}),
IssueAction::EditInEditor => Ok(Action::OpenEditor(card.filename)),
IssueAction::ViewIssue => Ok(Action::OpenViewer(card.filename)),
IssueAction::OpenAgent => Ok(Action::OpenAgent(card.filename)),
IssueAction::CloneIssue => {
self.clone_selected_into_form();
Ok(Action::None)
}
IssueAction::OpenMentions => {
open_detail()?;
Ok(Action::None)
}
IssueAction::RelatedPicker => {
open_detail()?;
self.open_related_picker();
Ok(Action::None)
}
IssueAction::FollowRelated => {
open_detail()?;
self.follow_related()
}
IssueAction::MoveTo { column } => self.start_move(MoveTarget::Named(column)),
IssueAction::Comment => {
open_detail()?;
self.bulk_action_targets = bulk_targets.clone();
self.mode = Mode::Comment {
buffer: String::new(),
};
Ok(Action::None)
}
IssueAction::AttachAsset => {
open_detail()?;
self.mode = Mode::Asset {
buffer: String::new(),
};
Ok(Action::None)
}
IssueAction::NewRelationship => {
if bulk_targets.is_some() {
self.open_bulk_modal(crate::ui::bulk::BatchAction::Link);
return Ok(Action::None);
}
open_detail()?;
self.mode = Mode::LinkCreate {
buffer: String::new(),
step: LinkStep::Target,
target: None,
};
Ok(Action::None)
}
IssueAction::Relationships => self.push_relationship_submenu(),
IssueAction::EditRelationship { id } => {
open_detail()?;
self.edit_relationship_by_id(&id)
}
IssueAction::RemoveRelationship { id } => {
open_detail()?;
self.remove_relationship_by_id(&id)
}
IssueAction::SetParent => {
open_detail()?;
self.bulk_action_targets = bulk_targets.clone();
self.mode = Mode::ParentPrompt {
buffer: String::new(),
};
Ok(Action::None)
}
IssueAction::RecordHealth => {
open_detail()?;
self.bulk_action_targets = bulk_targets.clone();
self.open_health_form()
}
IssueAction::AddTimeEntry => {
self.time_return = Some(Mode::Board);
self.time_form = Some(TimeForm::add(card.number.to_string()));
self.mode = Mode::TimeForm;
Ok(Action::None)
}
IssueAction::Archive => {
let filenames = self.bulk_filenames(card.number, &card.filename);
self.mode = Mode::ConfirmArchive {
filenames,
restore: false,
};
Ok(Action::None)
}
IssueAction::Restore => {
let filenames = self.bulk_filenames(card.number, &card.filename);
self.mode = Mode::ConfirmArchive {
filenames,
restore: true,
};
Ok(Action::None)
}
IssueAction::Delete => {
self.confirm_delete_target = Some(card.filename);
self.mode = Mode::ConfirmDelete;
Ok(Action::None)
}
IssueAction::Mark { on } => {
if on {
self.toggle_mark_selected();
} else {
self.unmark_selected_card();
}
Ok(Action::None)
}
IssueAction::Bulk { action } => {
self.open_bulk_modal(action);
Ok(Action::None)
}
IssueAction::ClearMarks => {
self.clear_marks();
Ok(Action::None)
}
IssueAction::Triage { decision } => self.open_triage_decision(card.number, decision),
IssueAction::RefreshBoard => {
self.refresh_corpus()?;
self.status_msg = Some("Refreshed".to_string());
Ok(Action::None)
}
}
}
pub(in crate::app) fn edit_relationship_by_id(&mut self, id: &str) -> Result<Action> {
let Some(index) = self.relationship_index(id) else {
self.status_msg = Some(format!("relationship {id} is no longer present"));
return Ok(Action::None);
};
if let Some(detail) = self.detail.as_mut() {
detail.rel_cursor = index;
}
self.mode = Mode::LinkEdit {
index,
buffer: String::new(),
step: LinkStep::Target,
target: None,
kind: None,
};
self.status_msg = Some(format!("Re-kind {id}"));
Ok(Action::None)
}
pub(in crate::app) fn relationship_index(&self, id: &str) -> Option<usize> {
self.detail.as_ref().and_then(|detail| {
detail
.relationships
.iter()
.position(|relationship| relationship.id == id)
})
}
pub(in crate::app) fn remove_relationship_by_id(&mut self, id: &str) -> Result<Action> {
let Some(index) = self.relationship_index(id) else {
self.status_msg = Some(format!("relationship {id} is no longer present"));
return Ok(Action::None);
};
if let Some(detail) = self.detail.as_mut() {
detail.rel_cursor = index;
}
let line = self
.detail
.as_ref()
.and_then(|detail| detail.relationships.get(index))
.map(crate::commands::link::relationship_line)
.unwrap_or_else(|| id.to_string());
self.status_msg = Some(format!("Remove {line}? (y to confirm)"));
self.mode = Mode::ConfirmRemoveRelationship { index };
Ok(Action::None)
}
pub(in crate::app) fn handle_comment_key(&mut self, key: KeyEvent) -> Result<Action> {
match key.code {
KeyCode::Esc => {
self.bulk_action_targets = None;
self.mode = Mode::Detail;
Ok(Action::None)
}
KeyCode::Enter => self.submit_comment(),
KeyCode::Char(c) => {
if let Mode::Comment { ref mut buffer } = self.mode {
buffer.push(c);
}
Ok(Action::None)
}
KeyCode::Backspace => {
if let Mode::Comment { ref mut buffer } = self.mode {
buffer.pop();
}
Ok(Action::None)
}
_ => Ok(Action::None),
}
}
pub(in crate::app) fn submit_comment(&mut self) -> Result<Action> {
let buffer = match &self.mode {
Mode::Comment { buffer } => buffer.clone(),
_ => return Ok(Action::None),
};
let text = buffer.trim().to_string();
if text.is_empty() {
self.bulk_action_targets = None;
self.status_msg = Some("Comment discarded (empty)".to_string());
self.mode = Mode::Detail;
return Ok(Action::None);
}
let author = crate::assignee::git_identity()
.map(|id| id.to_string())
.unwrap_or_else(|| "anonymous".to_string());
if let Some(targets) = self.bulk_action_targets.clone() {
self.bulk_action_targets = None;
let mut applied = 0usize;
let mut errors = Vec::new();
for number in targets {
let result = self
.store
.get(IssueSelector::Number(number))
.and_then(|mut issue| {
issue.add_comment(&author, &text);
let filename = issue.filename.clone();
self.store.update(&filename, &issue)
});
match result {
Ok(_) => applied += 1,
Err(error) => errors.push((number, error.to_string())),
}
}
self.refresh_corpus()?;
self.refresh_detail()?;
let suffix = if applied == 1 { "" } else { "s" };
let mut message = format!("Commented on {applied} issue{suffix}");
if let Some((number, error)) = errors.first() {
message.push_str(&format!("; {} refused (#{number}: {error})", errors.len()));
}
self.status_msg = Some(message);
self.mode = Mode::Detail;
return Ok(Action::None);
}
let filename = match self.detail.as_ref() {
Some(d) => d.issue.filename.clone(),
None => {
self.mode = Mode::Detail;
return Ok(Action::None);
}
};
let mut issue = self.store.get(IssueSelector::Filename(filename.clone()))?;
issue.add_comment(&author, &text);
issue.filename = self.store.update(&filename, &issue)?;
if let Some(ref mut d) = self.detail {
d.issue = issue;
d.scroll_offset.set(0);
}
self.refresh_corpus()?;
self.status_msg = Some(format!("Commented by {author}"));
self.mode = Mode::Detail;
Ok(Action::None)
}
pub(in crate::app) fn handle_asset_key(&mut self, key: KeyEvent) -> Result<Action> {
match key.code {
KeyCode::Esc => {
self.mode = Mode::Detail;
Ok(Action::None)
}
KeyCode::Enter => self.submit_asset(),
KeyCode::Char(c) => {
if let Mode::Asset { ref mut buffer } = self.mode {
buffer.push(c);
}
Ok(Action::None)
}
KeyCode::Backspace => {
if let Mode::Asset { ref mut buffer } = self.mode {
buffer.pop();
}
Ok(Action::None)
}
_ => Ok(Action::None),
}
}
pub(in crate::app) fn submit_asset(&mut self) -> Result<Action> {
let buffer = match &self.mode {
Mode::Asset { buffer } => buffer.clone(),
_ => return Ok(Action::None),
};
let path = std::path::PathBuf::from(buffer.trim());
let filename = match self.detail.as_ref() {
Some(d) => d.issue.filename.clone(),
None => {
self.mode = Mode::Detail;
return Ok(Action::None);
}
};
match self.store.add_asset(&filename, &path) {
Ok(added) => {
if let Ok(issue) = self.store.get(IssueSelector::Filename(filename))
&& let Some(ref mut d) = self.detail
{
d.issue = issue;
d.scroll_offset.set(0);
}
self.status_msg = Some(format!("Asset added: {}", added.link));
self.refresh_corpus()?;
self.mode = Mode::Detail;
Ok(Action::None)
}
Err(e) => {
self.status_msg = Some(format!("asset: {e}"));
Ok(Action::None)
}
}
}
}