Skip to main content

agent_air_tui/widgets/
batch_permission_panel.rs

1//! Batch permission panel widget for parallel tool permissions
2//!
3//! A Ratatui panel that displays multiple permission requests at once,
4//! using a simplified UI similar to the single PermissionPanel.
5//!
6//! # Navigation
7//! - Up/Down: Move between options
8//! - Enter/Space: Select option
9//! - Esc: Cancel (deny all)
10
11use crate::controller::TurnId;
12use crate::permissions::{
13    BatchPermissionRequest, BatchPermissionResponse, Grant, GrantTarget, PermissionLevel,
14};
15use crossterm::event::{KeyCode, KeyEvent};
16use ratatui::{
17    Frame,
18    layout::Rect,
19    style::{Modifier, Style},
20    text::{Line, Span},
21    widgets::{Block, Borders, Clear, Paragraph},
22};
23use std::collections::HashSet;
24
25use crate::themes::Theme;
26
27/// Default configuration values for BatchPermissionPanel
28pub mod defaults {
29    /// Maximum percentage of screen height the panel can use
30    pub const MAX_PANEL_PERCENT: u16 = 70;
31    /// Selection indicator for focused items
32    pub const SELECTION_INDICATOR: &str = " \u{203A} ";
33    /// Blank space for non-focused items (same width as indicator)
34    pub const NO_INDICATOR: &str = "   ";
35    /// Panel title (matches single permission panel)
36    pub const TITLE: &str = " Permission Request ";
37    /// Help text
38    pub const HELP_TEXT: &str =
39        " Up/Down: Navigate \u{00B7} Enter/Space: Select \u{00B7} Esc: Cancel";
40}
41
42/// Configuration for BatchPermissionPanel widget
43#[derive(Clone)]
44pub struct BatchPermissionPanelConfig {
45    /// Maximum percentage of screen height the panel can use
46    pub max_panel_percent: u16,
47    /// Selection indicator for focused items
48    pub selection_indicator: String,
49    /// Blank space for non-focused items
50    pub no_indicator: String,
51    /// Panel title
52    pub title: String,
53    /// Help text
54    pub help_text: String,
55}
56
57impl Default for BatchPermissionPanelConfig {
58    fn default() -> Self {
59        Self::new()
60    }
61}
62
63impl BatchPermissionPanelConfig {
64    /// Create a new BatchPermissionPanelConfig with default values
65    pub fn new() -> Self {
66        Self {
67            max_panel_percent: defaults::MAX_PANEL_PERCENT,
68            selection_indicator: defaults::SELECTION_INDICATOR.to_string(),
69            no_indicator: defaults::NO_INDICATOR.to_string(),
70            title: defaults::TITLE.to_string(),
71            help_text: defaults::HELP_TEXT.to_string(),
72        }
73    }
74}
75
76/// Options available for batch permission decisions
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum BatchPermissionOption {
79    /// Grant permission for these requests only (no persistent grant)
80    GrantOnce,
81    /// Grant permission for the remainder of the session (these specific resources)
82    GrantSession,
83    /// Grant permission for ALL operations of this type for the session
84    /// (only shown for homogeneous read/write batches)
85    GrantAllSimilar,
86    /// Deny all permission requests
87    Deny,
88}
89
90impl BatchPermissionOption {
91    /// Get the display label for this option
92    pub fn label(&self) -> &'static str {
93        match self {
94            BatchPermissionOption::GrantOnce => "Grant Once",
95            BatchPermissionOption::GrantSession => "Grant for Session",
96            BatchPermissionOption::GrantAllSimilar => "Allow All Similar",
97            BatchPermissionOption::Deny => "Deny",
98        }
99    }
100
101    /// Get the description for this option based on batch context
102    pub fn description(&self, count: usize, batch_type: Option<PermissionLevel>) -> String {
103        match self {
104            BatchPermissionOption::GrantOnce => {
105                format!("Allow only these {} requests", count)
106            }
107            BatchPermissionOption::GrantSession => match batch_type {
108                Some(PermissionLevel::Read) => format!("Allow reading these {} files", count),
109                Some(PermissionLevel::Write) => format!("Allow writing these {} files", count),
110                _ => "Allow these for the session".to_string(),
111            },
112            BatchPermissionOption::GrantAllSimilar => match batch_type {
113                Some(PermissionLevel::Read) => "Allow reading any file".to_string(),
114                Some(PermissionLevel::Write) => "Allow writing any file".to_string(),
115                _ => "Allow all similar actions".to_string(),
116            },
117            BatchPermissionOption::Deny => "Deny all requests".to_string(),
118        }
119    }
120
121    /// Returns true if this is a positive (grant) option
122    pub fn is_positive(&self) -> bool {
123        !matches!(self, BatchPermissionOption::Deny)
124    }
125}
126
127/// Result of handling a key event
128#[derive(Debug, Clone)]
129pub enum BatchKeyAction {
130    /// No action taken
131    None,
132    /// User submitted a batch response
133    Submitted(BatchPermissionResponse),
134    /// User cancelled (pressed Escape)
135    Cancelled(String),
136}
137
138/// State for the batch permission panel
139pub struct BatchPermissionPanel {
140    /// Whether the panel is active/visible
141    active: bool,
142    /// Batch ID for this permission request
143    batch_id: String,
144    /// Session ID
145    session_id: i64,
146    /// The batch permission request to display
147    batch: BatchPermissionRequest,
148    /// Turn ID for context
149    turn_id: Option<TurnId>,
150    /// Currently selected option index
151    selected_idx: usize,
152    /// Configuration for display customization
153    config: BatchPermissionPanelConfig,
154}
155
156impl BatchPermissionPanel {
157    /// Create a new inactive batch permission panel
158    pub fn new() -> Self {
159        Self::with_config(BatchPermissionPanelConfig::new())
160    }
161
162    /// Create a new inactive batch permission panel with custom configuration
163    pub fn with_config(config: BatchPermissionPanelConfig) -> Self {
164        Self {
165            active: false,
166            batch_id: String::new(),
167            session_id: 0,
168            batch: BatchPermissionRequest::new("", Vec::new()),
169            turn_id: None,
170            selected_idx: 0,
171            config,
172        }
173    }
174
175    /// Activate the panel with a batch permission request
176    pub fn activate(
177        &mut self,
178        session_id: i64,
179        batch: BatchPermissionRequest,
180        turn_id: Option<TurnId>,
181    ) {
182        self.active = true;
183        self.batch_id = batch.batch_id.clone();
184        self.session_id = session_id;
185        self.batch = batch;
186        self.turn_id = turn_id;
187        self.selected_idx = 0; // Default to first option (Grant Once)
188    }
189
190    /// Deactivate the panel
191    pub fn deactivate(&mut self) {
192        self.active = false;
193        self.batch_id.clear();
194        self.batch = BatchPermissionRequest::new("", Vec::new());
195        self.turn_id = None;
196        self.selected_idx = 0;
197    }
198
199    /// Check if the panel is active
200    pub fn is_active(&self) -> bool {
201        self.active
202    }
203
204    /// Get the current batch ID
205    pub fn batch_id(&self) -> &str {
206        &self.batch_id
207    }
208
209    /// Get the session ID
210    pub fn session_id(&self) -> i64 {
211        self.session_id
212    }
213
214    /// Get the current batch request
215    pub fn batch(&self) -> &BatchPermissionRequest {
216        &self.batch
217    }
218
219    /// Get the turn ID
220    pub fn turn_id(&self) -> Option<&TurnId> {
221        self.turn_id.as_ref()
222    }
223
224    /// Determines if this is a homogeneous file batch (all reads or all writes).
225    ///
226    /// Returns `Some(PermissionLevel::Read)` if all requests are file reads,
227    /// `Some(PermissionLevel::Write)` if all requests are file writes,
228    /// or `None` for commands or mixed types.
229    fn homogeneous_file_level(&self) -> Option<PermissionLevel> {
230        if self.batch.requests.is_empty() {
231            return None;
232        }
233
234        let mut all_reads = true;
235        let mut all_writes = true;
236
237        for request in &self.batch.requests {
238            // Must be a path target (not domain or command)
239            match &request.target {
240                GrantTarget::Path { .. } => {
241                    if request.required_level != PermissionLevel::Read {
242                        all_reads = false;
243                    }
244                    if request.required_level != PermissionLevel::Write {
245                        all_writes = false;
246                    }
247                }
248                GrantTarget::Domain { .. }
249                | GrantTarget::Command { .. }
250                | GrantTarget::Tool { .. } => {
251                    // Any non-path target means not homogeneous file batch
252                    return None;
253                }
254            }
255        }
256
257        if all_reads {
258            Some(PermissionLevel::Read)
259        } else if all_writes {
260            Some(PermissionLevel::Write)
261        } else {
262            None
263        }
264    }
265
266    /// Get available options based on batch content
267    fn available_options(&self) -> Vec<BatchPermissionOption> {
268        match self.homogeneous_file_level() {
269            Some(PermissionLevel::Read) | Some(PermissionLevel::Write) => {
270                // Homogeneous file batch: show all 4 options
271                vec![
272                    BatchPermissionOption::GrantOnce,
273                    BatchPermissionOption::GrantSession,
274                    BatchPermissionOption::GrantAllSimilar,
275                    BatchPermissionOption::Deny,
276                ]
277            }
278            _ => {
279                // Commands or mixed: only 3 options (no "Allow All" for commands)
280                vec![
281                    BatchPermissionOption::GrantOnce,
282                    BatchPermissionOption::GrantSession,
283                    BatchPermissionOption::Deny,
284                ]
285            }
286        }
287    }
288
289    /// Get the currently selected option
290    pub fn selected_option(&self) -> BatchPermissionOption {
291        let options = self.available_options();
292        options[self.selected_idx.min(options.len() - 1)]
293    }
294
295    /// Move selection to the next option
296    pub fn select_next(&mut self) {
297        let options = self.available_options();
298        self.selected_idx = (self.selected_idx + 1) % options.len();
299    }
300
301    /// Move selection to the previous option
302    pub fn select_prev(&mut self) {
303        let options = self.available_options();
304        if self.selected_idx == 0 {
305            self.selected_idx = options.len() - 1;
306        } else {
307            self.selected_idx -= 1;
308        }
309    }
310
311    /// Build a response based on the selected option
312    fn build_response(&self, option: BatchPermissionOption) -> BatchPermissionResponse {
313        match option {
314            BatchPermissionOption::GrantOnce => {
315                // No persistent grants, just approve requests
316                BatchPermissionResponse {
317                    batch_id: self.batch_id.clone(),
318                    approved_grants: self
319                        .batch
320                        .requests
321                        .iter()
322                        .map(|r| Grant::new(r.target.clone(), r.required_level))
323                        .collect(),
324                    denied_requests: HashSet::new(),
325                    auto_approved: HashSet::new(),
326                }
327            }
328            BatchPermissionOption::GrantSession => {
329                // Use suggested grants from batch
330                BatchPermissionResponse::all_granted(
331                    &self.batch_id,
332                    self.batch.suggested_grants.clone(),
333                )
334            }
335            BatchPermissionOption::GrantAllSimilar => {
336                // Create broad grant for all paths with same level
337                let level = self
338                    .homogeneous_file_level()
339                    .unwrap_or(PermissionLevel::Read);
340                let broad_grant = Grant::new(GrantTarget::path("/", true), level);
341                BatchPermissionResponse::all_granted(&self.batch_id, vec![broad_grant])
342            }
343            BatchPermissionOption::Deny => {
344                let request_ids: Vec<String> =
345                    self.batch.requests.iter().map(|r| r.id.clone()).collect();
346                BatchPermissionResponse::all_denied(&self.batch_id, request_ids)
347            }
348        }
349    }
350
351    /// Handle a key event
352    pub fn process_key(&mut self, key: KeyEvent) -> BatchKeyAction {
353        if !self.active {
354            return BatchKeyAction::None;
355        }
356
357        match key.code {
358            // Navigation
359            KeyCode::Up | KeyCode::Char('k') => {
360                self.select_prev();
361                BatchKeyAction::None
362            }
363            KeyCode::Down | KeyCode::Char('j') => {
364                self.select_next();
365                BatchKeyAction::None
366            }
367
368            // Selection
369            KeyCode::Enter | KeyCode::Char(' ') => {
370                let option = self.selected_option();
371                let response = self.build_response(option);
372                BatchKeyAction::Submitted(response)
373            }
374
375            // Cancel
376            KeyCode::Esc => {
377                let batch_id = self.batch_id.clone();
378                BatchKeyAction::Cancelled(batch_id)
379            }
380
381            _ => BatchKeyAction::None,
382        }
383    }
384
385    /// Calculate the height needed for the panel
386    pub fn panel_height(&self, max_height: u16) -> u16 {
387        // Help text: 1
388        // Blank after help: 1
389        // "Permissions requested:" label: 1
390        // Blank after label: 1
391        // Permissions list: 1 per request
392        // Separator (blank): 1
393        // Options: 3 or 4
394        // Bottom padding: 1
395        // Borders: 2
396
397        let mut lines = 6u16; // Help (1) + blank (1) + label (1) + blank (1) + separator (1) + bottom padding (1)
398        lines += self.batch.requests.len().min(10) as u16; // Cap at 10 to prevent overflow
399        lines += self.available_options().len() as u16;
400        lines += 2; // Borders
401
402        let max_from_percent = (max_height * self.config.max_panel_percent) / 100;
403        lines
404            .min(max_from_percent)
405            .min(max_height.saturating_sub(6))
406    }
407
408    /// Render the panel
409    pub fn render_panel(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
410        if !self.active {
411            return;
412        }
413
414        // Clear the area first
415        frame.render_widget(Clear, area);
416
417        let inner_width = area.width.saturating_sub(4) as usize;
418        let mut lines: Vec<Line> = Vec::new();
419        let batch_type = self.homogeneous_file_level();
420
421        // Help text at top
422        lines.push(Line::from(Span::styled(
423            truncate_text(&self.config.help_text, inner_width),
424            theme.help_text(),
425        )));
426        lines.push(Line::from("")); // Blank line
427
428        // "Permissions requested:" label
429        lines.push(Line::from(Span::styled(
430            " Permissions requested:",
431            theme.muted_text().add_modifier(Modifier::BOLD),
432        )));
433        lines.push(Line::from("")); // Blank line
434
435        // Permissions list (read-only, no focus indicators or checkboxes)
436        let max_requests = 10; // Limit displayed requests to prevent overflow
437        for (idx, request) in self.batch.requests.iter().take(max_requests).enumerate() {
438            let (target_icon, target_desc) = format_target(&request.target);
439            let level_str = format_level(request.required_level);
440
441            lines.push(Line::from(vec![
442                Span::styled("   ", Style::default()),
443                Span::styled(format!("{} ", target_icon), theme.category()),
444                Span::styled(format!("{}: ", level_str), theme.muted_text()),
445                Span::styled(
446                    truncate_text(&target_desc, inner_width.saturating_sub(20)),
447                    theme.resource(),
448                ),
449            ]));
450
451            // Show "and N more..." if there are too many
452            if idx == max_requests - 1 && self.batch.requests.len() > max_requests {
453                let remaining = self.batch.requests.len() - max_requests;
454                lines.push(Line::from(vec![
455                    Span::styled("   ", Style::default()),
456                    Span::styled(format!("   ... and {} more", remaining), theme.muted_text()),
457                ]));
458            }
459        }
460
461        // Separator line
462        lines.push(Line::from(""));
463
464        // Options
465        let options = self.available_options();
466        let request_count = self.batch.requests.len();
467
468        for (idx, option) in options.iter().enumerate() {
469            let is_selected = idx == self.selected_idx;
470            let prefix = if is_selected {
471                &self.config.selection_indicator
472            } else {
473                &self.config.no_indicator
474            };
475
476            let description = option.description(request_count, batch_type);
477
478            let (label_style, desc_style) = if is_selected {
479                if option.is_positive() {
480                    (theme.button_confirm_focused(), theme.focused_text())
481                } else {
482                    (theme.button_cancel_focused(), theme.focused_text())
483                }
484            } else if option.is_positive() {
485                (theme.button_confirm(), theme.muted_text())
486            } else {
487                (theme.button_cancel(), theme.muted_text())
488            };
489
490            let indicator_style = if is_selected {
491                theme.focus_indicator()
492            } else {
493                theme.muted_text()
494            };
495
496            lines.push(Line::from(vec![
497                Span::styled(prefix.clone(), indicator_style),
498                Span::styled(option.label(), label_style),
499                Span::styled(" - ", theme.muted_text()),
500                Span::styled(description, desc_style),
501            ]));
502        }
503
504        // Bottom padding
505        lines.push(Line::from(""));
506
507        // Build the block
508        let block = Block::default()
509            .borders(Borders::ALL)
510            .border_style(theme.warning())
511            .title(Span::styled(
512                self.config.title.clone(),
513                theme.warning().add_modifier(Modifier::BOLD),
514            ));
515
516        let paragraph = Paragraph::new(lines).block(block);
517        frame.render_widget(paragraph, area);
518    }
519}
520
521impl Default for BatchPermissionPanel {
522    fn default() -> Self {
523        Self::new()
524    }
525}
526
527/// Format a grant target for display
528fn format_target(target: &GrantTarget) -> (&'static str, String) {
529    match target {
530        GrantTarget::Path { path, recursive } => {
531            let rec_suffix = if *recursive { " (recursive)" } else { "" };
532            ("\u{1F4C4}", format!("{}{}", path.display(), rec_suffix))
533        }
534        GrantTarget::Domain { pattern } => ("\u{2194}", pattern.clone()),
535        GrantTarget::Command { pattern } => ("\u{2295}", pattern.clone()),
536        GrantTarget::Tool { tool_name } => ("\u{1F527}", tool_name.clone()),
537    }
538}
539
540/// Format a permission level for display
541fn format_level(level: PermissionLevel) -> &'static str {
542    match level {
543        PermissionLevel::None => "None",
544        PermissionLevel::Read => "Read File",
545        PermissionLevel::Write => "Write File",
546        PermissionLevel::Execute => "Execute",
547        PermissionLevel::Admin => "Admin",
548    }
549}
550
551/// Truncate text to fit within a maximum width
552fn truncate_text(text: &str, max_width: usize) -> String {
553    if text.chars().count() <= max_width {
554        text.to_string()
555    } else {
556        let truncated: String = text.chars().take(max_width.saturating_sub(3)).collect();
557        format!("{}...", truncated)
558    }
559}
560
561// --- Widget trait implementation ---
562
563use super::{Widget, WidgetAction, WidgetKeyContext, WidgetKeyResult, widget_ids};
564use std::any::Any;
565
566impl Widget for BatchPermissionPanel {
567    fn id(&self) -> &'static str {
568        widget_ids::BATCH_PERMISSION_PANEL
569    }
570
571    fn priority(&self) -> u8 {
572        200 // High priority - modal panel
573    }
574
575    fn is_active(&self) -> bool {
576        self.active
577    }
578
579    fn handle_key(&mut self, key: KeyEvent, ctx: &WidgetKeyContext) -> WidgetKeyResult {
580        if !self.active {
581            return WidgetKeyResult::NotHandled;
582        }
583
584        // Use NavigationHelper for navigation keys
585        if ctx.nav.is_move_up(&key) {
586            self.select_prev();
587            return WidgetKeyResult::Handled;
588        }
589        if ctx.nav.is_move_down(&key) {
590            self.select_next();
591            return WidgetKeyResult::Handled;
592        }
593
594        // Selection using nav helper
595        if ctx.nav.is_select(&key) {
596            let option = self.selected_option();
597            let response = self.build_response(option);
598            return WidgetKeyResult::Action(WidgetAction::SubmitBatchPermission {
599                batch_id: self.batch_id.clone(),
600                response,
601            });
602        }
603
604        // Cancel using nav helper
605        if ctx.nav.is_cancel(&key) {
606            let batch_id = self.batch_id.clone();
607            return WidgetKeyResult::Action(WidgetAction::CancelBatchPermission { batch_id });
608        }
609
610        // j/k vim-style navigation (kept for consistency)
611        match key.code {
612            KeyCode::Char('k') => {
613                self.select_prev();
614                WidgetKeyResult::Handled
615            }
616            KeyCode::Char('j') => {
617                self.select_next();
618                WidgetKeyResult::Handled
619            }
620            _ => WidgetKeyResult::NotHandled,
621        }
622    }
623
624    fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) {
625        self.render_panel(frame, area, theme);
626    }
627
628    fn required_height(&self, max_height: u16) -> u16 {
629        if self.active {
630            self.panel_height(max_height)
631        } else {
632            0
633        }
634    }
635
636    fn blocks_input(&self) -> bool {
637        self.active
638    }
639
640    fn is_overlay(&self) -> bool {
641        false
642    }
643
644    fn as_any(&self) -> &dyn Any {
645        self
646    }
647
648    fn as_any_mut(&mut self) -> &mut dyn Any {
649        self
650    }
651
652    fn into_any(self: Box<Self>) -> Box<dyn Any> {
653        self
654    }
655}
656
657#[cfg(test)]
658mod tests {
659    use super::*;
660    use crate::permissions::PermissionRequest;
661
662    fn create_read_batch() -> BatchPermissionRequest {
663        let requests = vec![
664            PermissionRequest::file_read("1", "/project/src/main.rs"),
665            PermissionRequest::file_read("2", "/project/src/lib.rs"),
666            PermissionRequest::file_read("3", "/project/src/utils.rs"),
667        ];
668        BatchPermissionRequest::new("batch-1", requests)
669    }
670
671    fn create_write_batch() -> BatchPermissionRequest {
672        let requests = vec![
673            PermissionRequest::file_write("1", "/project/src/main.rs"),
674            PermissionRequest::file_write("2", "/project/src/lib.rs"),
675        ];
676        BatchPermissionRequest::new("batch-2", requests)
677    }
678
679    fn create_mixed_batch() -> BatchPermissionRequest {
680        let requests = vec![
681            PermissionRequest::file_read("1", "/project/src/main.rs"),
682            PermissionRequest::file_write("2", "/project/src/lib.rs"),
683        ];
684        BatchPermissionRequest::new("batch-3", requests)
685    }
686
687    fn create_command_batch() -> BatchPermissionRequest {
688        let requests = vec![
689            PermissionRequest::command_execute("1", "git status"),
690            PermissionRequest::command_execute("2", "git diff"),
691        ];
692        BatchPermissionRequest::new("batch-4", requests)
693    }
694
695    #[test]
696    fn test_panel_activation() {
697        let mut panel = BatchPermissionPanel::new();
698        assert!(!panel.is_active());
699
700        let batch = create_read_batch();
701        panel.activate(1, batch, None);
702
703        assert!(panel.is_active());
704        assert_eq!(panel.batch_id(), "batch-1");
705        assert_eq!(panel.session_id(), 1);
706        assert_eq!(panel.selected_idx, 0);
707
708        panel.deactivate();
709        assert!(!panel.is_active());
710    }
711
712    #[test]
713    fn test_homogeneous_read_batch() {
714        let mut panel = BatchPermissionPanel::new();
715        let batch = create_read_batch();
716        panel.activate(1, batch, None);
717
718        assert_eq!(panel.homogeneous_file_level(), Some(PermissionLevel::Read));
719        assert_eq!(panel.available_options().len(), 4); // Includes GrantAllSimilar
720    }
721
722    #[test]
723    fn test_homogeneous_write_batch() {
724        let mut panel = BatchPermissionPanel::new();
725        let batch = create_write_batch();
726        panel.activate(1, batch, None);
727
728        assert_eq!(panel.homogeneous_file_level(), Some(PermissionLevel::Write));
729        assert_eq!(panel.available_options().len(), 4); // Includes GrantAllSimilar
730    }
731
732    #[test]
733    fn test_mixed_batch_no_grant_all() {
734        let mut panel = BatchPermissionPanel::new();
735        let batch = create_mixed_batch();
736        panel.activate(1, batch, None);
737
738        assert_eq!(panel.homogeneous_file_level(), None);
739        assert_eq!(panel.available_options().len(), 3); // No GrantAllSimilar
740    }
741
742    #[test]
743    fn test_command_batch_no_grant_all() {
744        let mut panel = BatchPermissionPanel::new();
745        let batch = create_command_batch();
746        panel.activate(1, batch, None);
747
748        assert_eq!(panel.homogeneous_file_level(), None);
749        assert_eq!(panel.available_options().len(), 3); // No GrantAllSimilar for commands
750    }
751
752    #[test]
753    fn test_navigation() {
754        let mut panel = BatchPermissionPanel::new();
755        let batch = create_read_batch();
756        panel.activate(1, batch, None);
757
758        // Default is first option
759        assert_eq!(panel.selected_option(), BatchPermissionOption::GrantOnce);
760
761        // Move down
762        panel.select_next();
763        assert_eq!(panel.selected_option(), BatchPermissionOption::GrantSession);
764
765        panel.select_next();
766        assert_eq!(
767            panel.selected_option(),
768            BatchPermissionOption::GrantAllSimilar
769        );
770
771        panel.select_next();
772        assert_eq!(panel.selected_option(), BatchPermissionOption::Deny);
773
774        // Wrap around
775        panel.select_next();
776        assert_eq!(panel.selected_option(), BatchPermissionOption::GrantOnce);
777
778        // Move up (wrap)
779        panel.select_prev();
780        assert_eq!(panel.selected_option(), BatchPermissionOption::Deny);
781    }
782
783    #[test]
784    fn test_grant_once_response() {
785        let mut panel = BatchPermissionPanel::new();
786        let batch = create_read_batch();
787        panel.activate(1, batch, None);
788
789        let response = panel.build_response(BatchPermissionOption::GrantOnce);
790        assert_eq!(response.batch_id, "batch-1");
791        assert_eq!(response.approved_grants.len(), 3); // One grant per request
792        assert!(response.denied_requests.is_empty());
793    }
794
795    #[test]
796    fn test_grant_session_response() {
797        let mut panel = BatchPermissionPanel::new();
798        let batch = create_read_batch();
799        panel.activate(1, batch, None);
800
801        let response = panel.build_response(BatchPermissionOption::GrantSession);
802        assert_eq!(response.batch_id, "batch-1");
803        assert!(!response.approved_grants.is_empty()); // Uses suggested grants
804        assert!(response.denied_requests.is_empty());
805    }
806
807    #[test]
808    fn test_grant_all_similar_response() {
809        let mut panel = BatchPermissionPanel::new();
810        let batch = create_read_batch();
811        panel.activate(1, batch, None);
812
813        let response = panel.build_response(BatchPermissionOption::GrantAllSimilar);
814        assert_eq!(response.batch_id, "batch-1");
815        assert_eq!(response.approved_grants.len(), 1); // Single broad grant
816        assert!(response.denied_requests.is_empty());
817
818        // Check that it's a broad grant
819        let grant = &response.approved_grants[0];
820        assert_eq!(grant.level, PermissionLevel::Read);
821        if let GrantTarget::Path { path, recursive } = &grant.target {
822            assert_eq!(path.to_str().unwrap(), "/");
823            assert!(*recursive);
824        } else {
825            panic!("Expected path target");
826        }
827    }
828
829    #[test]
830    fn test_deny_response() {
831        let mut panel = BatchPermissionPanel::new();
832        let batch = create_read_batch();
833        panel.activate(1, batch, None);
834
835        let response = panel.build_response(BatchPermissionOption::Deny);
836        assert_eq!(response.batch_id, "batch-1");
837        assert!(response.approved_grants.is_empty());
838        assert_eq!(response.denied_requests.len(), 3);
839    }
840
841    #[test]
842    fn test_option_descriptions() {
843        let option = BatchPermissionOption::GrantOnce;
844        assert_eq!(
845            option.description(3, Some(PermissionLevel::Read)),
846            "Allow only these 3 requests"
847        );
848
849        let option = BatchPermissionOption::GrantSession;
850        assert_eq!(
851            option.description(3, Some(PermissionLevel::Read)),
852            "Allow reading these 3 files"
853        );
854        assert_eq!(
855            option.description(2, Some(PermissionLevel::Write)),
856            "Allow writing these 2 files"
857        );
858        assert_eq!(option.description(5, None), "Allow these for the session");
859
860        let option = BatchPermissionOption::GrantAllSimilar;
861        assert_eq!(
862            option.description(3, Some(PermissionLevel::Read)),
863            "Allow reading any file"
864        );
865        assert_eq!(
866            option.description(2, Some(PermissionLevel::Write)),
867            "Allow writing any file"
868        );
869
870        let option = BatchPermissionOption::Deny;
871        assert_eq!(option.description(3, None), "Deny all requests");
872    }
873}