use std::cell::RefCell;
use std::time::{Duration, Instant};
use codewhale_config::ToolAskRule;
use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
use ratatui::layout::Rect;
use crate::config::ApprovalDefaultSelection;
use crate::localization::{Locale, MessageId, tr};
use crate::tools::canonical_action::canonical_action_alias;
use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent};
use crate::tui::widgets::{ApprovalWidget, Renderable};
#[cfg(test)]
use super::RiskLevel;
use super::previews::exact_edit_file_preview_lines;
use super::{ApprovalRequest, ReviewDecision};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApprovalOption {
ApproveOnce,
ApproveAlways,
AllowExactRepo,
Deny,
Abort,
}
impl ApprovalOption {
const ORDER: [ApprovalOption; 4] = [
ApprovalOption::ApproveOnce,
ApprovalOption::ApproveAlways,
ApprovalOption::Deny,
ApprovalOption::Abort,
];
const ORDER_WITH_PERSISTENT_ALLOW: [ApprovalOption; 5] = [
ApprovalOption::ApproveOnce,
ApprovalOption::ApproveAlways,
ApprovalOption::AllowExactRepo,
ApprovalOption::Deny,
ApprovalOption::Abort,
];
const WORKFLOW_ORDER: [ApprovalOption; 3] = [
ApprovalOption::ApproveOnce,
ApprovalOption::Deny,
ApprovalOption::Abort,
];
fn order_for(request: &ApprovalRequest) -> &'static [ApprovalOption] {
if request.tool_name == "workflow" {
&Self::WORKFLOW_ORDER
} else if request.can_save_allow_rule() {
&Self::ORDER_WITH_PERSISTENT_ALLOW
} else {
&Self::ORDER
}
}
fn from_index_for(request: &ApprovalRequest, idx: usize) -> ApprovalOption {
Self::order_for(request)
.get(idx)
.copied()
.unwrap_or(Self::Abort)
}
fn index_for(self, request: &ApprovalRequest) -> usize {
Self::order_for(request)
.iter()
.position(|o| *o == self)
.unwrap_or(Self::order_for(request).len().saturating_sub(1))
}
fn decision(self) -> ReviewDecision {
match self {
ApprovalOption::ApproveOnce => ReviewDecision::Approved,
ApprovalOption::ApproveAlways => ReviewDecision::ApprovedForSession,
ApprovalOption::AllowExactRepo => ReviewDecision::Approved,
ApprovalOption::Deny => ReviewDecision::Denied,
ApprovalOption::Abort => ReviewDecision::Abort,
}
}
}
#[derive(Debug, Clone)]
pub struct ApprovalView {
request: ApprovalRequest,
pub(super) selected: usize,
pub(super) row_hitboxes: RefCell<Vec<Rect>>,
locale: Locale,
pub(super) timeout: Option<Duration>,
requested_at: Instant,
pub(crate) collapsed: bool,
}
impl ApprovalView {
#[cfg(test)]
pub fn new(request: ApprovalRequest) -> Self {
Self::new_for_locale(request, Locale::En)
}
#[cfg(test)]
pub fn new_for_locale(request: ApprovalRequest, locale: Locale) -> Self {
Self::new_with_default_selection(request, locale, ApprovalDefaultSelection::default())
}
pub fn new_with_default_selection(
request: ApprovalRequest,
locale: Locale,
default_selection: ApprovalDefaultSelection,
) -> Self {
let selected = match default_selection {
ApprovalDefaultSelection::Deny => ApprovalOption::Deny,
ApprovalDefaultSelection::AllowOnce => ApprovalOption::ApproveOnce,
}
.index_for(&request);
Self {
request,
selected,
row_hitboxes: RefCell::new(Vec::new()),
locale,
timeout: None,
requested_at: Instant::now(),
collapsed: false,
}
}
pub(super) fn select_prev(&mut self) {
let len = ApprovalOption::order_for(&self.request).len();
self.selected = crate::tui::list_nav::wrap_index(self.selected, len, -1);
}
pub(super) fn select_next(&mut self) {
let len = ApprovalOption::order_for(&self.request).len();
self.selected = crate::tui::list_nav::wrap_index(self.selected, len, 1);
}
pub(super) fn current_option(&self) -> ApprovalOption {
ApprovalOption::from_index_for(&self.request, self.selected)
}
#[must_use]
pub fn is_workflow_plan_approval(&self) -> bool {
self.request.tool_name == "workflow"
}
#[cfg(test)]
pub(super) fn current_decision(&self) -> ReviewDecision {
self.current_option().decision()
}
pub fn selected(&self) -> usize {
self.selected
}
pub(crate) fn set_mouse_hitboxes(&self, hitboxes: Vec<Rect>) {
*self.row_hitboxes.borrow_mut() = hitboxes;
}
#[cfg(test)]
pub fn risk(&self) -> RiskLevel {
self.request.risk
}
pub(crate) fn locale(&self) -> Locale {
self.locale
}
fn commit_option(&mut self, option: ApprovalOption) -> ViewAction {
self.selected = option.index_for(&self.request);
if option == ApprovalOption::AllowExactRepo && self.request.can_save_allow_rule() {
self.emit_decision_with_rules(
option.decision(),
false,
self.request.persistent_allow_rules.clone(),
)
} else {
self.emit_decision(option.decision(), false)
}
}
fn emit_decision(&self, decision: ReviewDecision, timed_out: bool) -> ViewAction {
self.emit_decision_with_rules(decision, timed_out, Vec::new())
}
fn emit_decision_with_rules(
&self,
decision: ReviewDecision,
timed_out: bool,
persistent_rules: Vec<ToolAskRule>,
) -> ViewAction {
ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
tool_id: self.request.id.clone(),
tool_name: self.request.tool_name.clone(),
decision,
timed_out,
approval_key: self.request.approval_key.clone(),
approval_grouping_key: self.request.approval_grouping_key.clone(),
persistent_rules,
})
}
fn emit_params_pager(&self) -> ViewAction {
let locale = self.locale();
let about_label = tr(locale, MessageId::ApprovalLabelAbout);
let impact_label = tr(locale, MessageId::ApprovalLabelImpact);
let mut content = String::new();
content.push_str(&about_label);
content.push_str(&self.request.description_for_locale(locale));
content.push('\n');
for impact in self.request.impacts_for_locale(locale) {
content.push_str(&impact_label);
content.push_str(&impact);
content.push('\n');
}
content.push('\n');
if canonical_action_alias(&self.request.tool_name, &self.request.params) == "edit_file"
&& let Some(preview_lines) = exact_edit_file_preview_lines(&self.request.params, locale)
{
content.push_str(&tr(locale, MessageId::ApprovalLabelPreview));
content.push_str(":\n");
for line in preview_lines {
content.push_str(&line);
content.push('\n');
}
content.push('\n');
}
content.push_str(
&serde_json::to_string_pretty(&self.request.params)
.unwrap_or_else(|_| self.request.params.to_string()),
);
ViewAction::Emit(ViewEvent::OpenTextPager {
title: format!("Tool Params: {}", self.request.tool_name),
content,
})
}
fn is_timed_out(&self) -> bool {
match self.timeout {
Some(timeout) => self.requested_at.elapsed() >= timeout,
None => false,
}
}
}
impl ModalView for ApprovalView {
fn kind(&self) -> ModalKind {
ModalKind::Approval
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
match key.code {
KeyCode::Tab => {
self.collapsed = !self.collapsed;
ViewAction::None
}
KeyCode::Up | KeyCode::Char('k') => {
self.select_prev();
ViewAction::None
}
KeyCode::Down | KeyCode::Char('j') => {
self.select_next();
ViewAction::None
}
KeyCode::Enter => self.commit_option(self.current_option()),
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Char('1') => {
self.commit_option(ApprovalOption::ApproveOnce)
}
KeyCode::Char('a') | KeyCode::Char('A') | KeyCode::Char('2')
if !self.is_workflow_plan_approval() =>
{
self.commit_option(ApprovalOption::ApproveAlways)
}
KeyCode::Char('p') | KeyCode::Char('P') if self.request.can_save_allow_rule() => {
self.commit_option(ApprovalOption::AllowExactRepo)
}
KeyCode::Char('e') | KeyCode::Char('E') | KeyCode::Char('2')
if self.is_workflow_plan_approval() =>
{
self.commit_option(ApprovalOption::Deny)
}
KeyCode::Char('s') | KeyCode::Char('S') if self.request.can_save_ask_rule() => self
.emit_decision_with_rules(
ReviewDecision::Approved,
false,
self.request.persistent_ask_rules.clone(),
),
KeyCode::Char('n')
| KeyCode::Char('N')
| KeyCode::Char('d')
| KeyCode::Char('D')
| KeyCode::Char('3') => {
if self.is_workflow_plan_approval() {
self.commit_option(ApprovalOption::Abort)
} else {
self.commit_option(ApprovalOption::Deny)
}
}
_ if crate::tui::shell_key_routing::is_tool_details_shortcut(&key) => {
self.emit_params_pager()
}
KeyCode::Esc => self.emit_decision(ReviewDecision::Abort, false),
_ => ViewAction::None,
}
}
fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
match mouse.kind {
MouseEventKind::ScrollUp => {
self.select_prev();
ViewAction::None
}
MouseEventKind::ScrollDown => {
self.select_next();
ViewAction::None
}
MouseEventKind::Down(MouseButton::Left) => {
let clicked = self.row_hitboxes.borrow().iter().position(|rect| {
rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
});
if let Some(index) = clicked {
return self
.commit_option(ApprovalOption::from_index_for(&self.request, index));
}
ViewAction::None
}
_ => ViewAction::None,
}
}
fn render(&self, area: ratatui::layout::Rect, buf: &mut ratatui::buffer::Buffer) {
let approval_widget = ApprovalWidget::new(&self.request, self);
approval_widget.render(area, buf);
}
fn occupied_region(&self, area: ratatui::layout::Rect) -> ratatui::layout::Rect {
ApprovalWidget::new(&self.request, self).inline_region(area)
}
fn tick(&mut self) -> ViewAction {
if self.is_timed_out() {
return self.emit_decision(ReviewDecision::Denied, true);
}
ViewAction::None
}
}