Skip to main content

agent_core_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    layout::Rect,
18    style::{Modifier, Style},
19    text::{Line, Span},
20    widgets::{Block, Borders, Clear, Paragraph},
21    Frame,
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 { .. } | GrantTarget::Command { .. } => {
249                    // Any non-path target means not homogeneous file batch
250                    return None;
251                }
252            }
253        }
254
255        if all_reads {
256            Some(PermissionLevel::Read)
257        } else if all_writes {
258            Some(PermissionLevel::Write)
259        } else {
260            None
261        }
262    }
263
264    /// Get available options based on batch content
265    fn available_options(&self) -> Vec<BatchPermissionOption> {
266        match self.homogeneous_file_level() {
267            Some(PermissionLevel::Read) | Some(PermissionLevel::Write) => {
268                // Homogeneous file batch: show all 4 options
269                vec![
270                    BatchPermissionOption::GrantOnce,
271                    BatchPermissionOption::GrantSession,
272                    BatchPermissionOption::GrantAllSimilar,
273                    BatchPermissionOption::Deny,
274                ]
275            }
276            _ => {
277                // Commands or mixed: only 3 options (no "Allow All" for commands)
278                vec![
279                    BatchPermissionOption::GrantOnce,
280                    BatchPermissionOption::GrantSession,
281                    BatchPermissionOption::Deny,
282                ]
283            }
284        }
285    }
286
287    /// Get the currently selected option
288    pub fn selected_option(&self) -> BatchPermissionOption {
289        let options = self.available_options();
290        options[self.selected_idx.min(options.len() - 1)]
291    }
292
293    /// Move selection to the next option
294    pub fn select_next(&mut self) {
295        let options = self.available_options();
296        self.selected_idx = (self.selected_idx + 1) % options.len();
297    }
298
299    /// Move selection to the previous option
300    pub fn select_prev(&mut self) {
301        let options = self.available_options();
302        if self.selected_idx == 0 {
303            self.selected_idx = options.len() - 1;
304        } else {
305            self.selected_idx -= 1;
306        }
307    }
308
309    /// Build a response based on the selected option
310    fn build_response(&self, option: BatchPermissionOption) -> BatchPermissionResponse {
311        match option {
312            BatchPermissionOption::GrantOnce => {
313                // No persistent grants, just approve requests
314                BatchPermissionResponse {
315                    batch_id: self.batch_id.clone(),
316                    approved_grants: self
317                        .batch
318                        .requests
319                        .iter()
320                        .map(|r| Grant::new(r.target.clone(), r.required_level))
321                        .collect(),
322                    denied_requests: HashSet::new(),
323                    auto_approved: HashSet::new(),
324                }
325            }
326            BatchPermissionOption::GrantSession => {
327                // Use suggested grants from batch
328                BatchPermissionResponse::all_granted(
329                    &self.batch_id,
330                    self.batch.suggested_grants.clone(),
331                )
332            }
333            BatchPermissionOption::GrantAllSimilar => {
334                // Create broad grant for all paths with same level
335                let level = self.homogeneous_file_level().unwrap_or(PermissionLevel::Read);
336                let broad_grant = Grant::new(GrantTarget::path("/", true), level);
337                BatchPermissionResponse::all_granted(&self.batch_id, vec![broad_grant])
338            }
339            BatchPermissionOption::Deny => {
340                let request_ids: Vec<String> =
341                    self.batch.requests.iter().map(|r| r.id.clone()).collect();
342                BatchPermissionResponse::all_denied(&self.batch_id, request_ids)
343            }
344        }
345    }
346
347    /// Handle a key event
348    pub fn process_key(&mut self, key: KeyEvent) -> BatchKeyAction {
349        if !self.active {
350            return BatchKeyAction::None;
351        }
352
353        match key.code {
354            // Navigation
355            KeyCode::Up | KeyCode::Char('k') => {
356                self.select_prev();
357                BatchKeyAction::None
358            }
359            KeyCode::Down | KeyCode::Char('j') => {
360                self.select_next();
361                BatchKeyAction::None
362            }
363
364            // Selection
365            KeyCode::Enter | KeyCode::Char(' ') => {
366                let option = self.selected_option();
367                let response = self.build_response(option);
368                BatchKeyAction::Submitted(response)
369            }
370
371            // Cancel
372            KeyCode::Esc => {
373                let batch_id = self.batch_id.clone();
374                BatchKeyAction::Cancelled(batch_id)
375            }
376
377            _ => BatchKeyAction::None,
378        }
379    }
380
381    /// Calculate the height needed for the panel
382    pub fn panel_height(&self, max_height: u16) -> u16 {
383        // Help text: 1
384        // Blank after help: 1
385        // "Permissions requested:" label: 1
386        // Blank after label: 1
387        // Permissions list: 1 per request
388        // Separator (blank): 1
389        // Options: 3 or 4
390        // Bottom padding: 1
391        // Borders: 2
392
393        let mut lines = 6u16; // Help (1) + blank (1) + label (1) + blank (1) + separator (1) + bottom padding (1)
394        lines += self.batch.requests.len().min(10) as u16; // Cap at 10 to prevent overflow
395        lines += self.available_options().len() as u16;
396        lines += 2; // Borders
397
398        let max_from_percent = (max_height * self.config.max_panel_percent) / 100;
399        lines.min(max_from_percent).min(max_height.saturating_sub(6))
400    }
401
402    /// Render the panel
403    pub fn render_panel(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
404        if !self.active {
405            return;
406        }
407
408        // Clear the area first
409        frame.render_widget(Clear, area);
410
411        let inner_width = area.width.saturating_sub(4) as usize;
412        let mut lines: Vec<Line> = Vec::new();
413        let batch_type = self.homogeneous_file_level();
414
415        // Help text at top
416        lines.push(Line::from(Span::styled(
417            truncate_text(&self.config.help_text, inner_width),
418            theme.help_text(),
419        )));
420        lines.push(Line::from("")); // Blank line
421
422        // "Permissions requested:" label
423        lines.push(Line::from(Span::styled(
424            " Permissions requested:",
425            theme.muted_text().add_modifier(Modifier::BOLD),
426        )));
427        lines.push(Line::from("")); // Blank line
428
429        // Permissions list (read-only, no focus indicators or checkboxes)
430        let max_requests = 10; // Limit displayed requests to prevent overflow
431        for (idx, request) in self.batch.requests.iter().take(max_requests).enumerate() {
432            let (target_icon, target_desc) = format_target(&request.target);
433            let level_str = format_level(request.required_level);
434
435            lines.push(Line::from(vec![
436                Span::styled("   ", Style::default()),
437                Span::styled(format!("{} ", target_icon), theme.category()),
438                Span::styled(format!("{}: ", level_str), theme.muted_text()),
439                Span::styled(
440                    truncate_text(&target_desc, inner_width.saturating_sub(20)),
441                    theme.resource(),
442                ),
443            ]));
444
445            // Show "and N more..." if there are too many
446            if idx == max_requests - 1 && self.batch.requests.len() > max_requests {
447                let remaining = self.batch.requests.len() - max_requests;
448                lines.push(Line::from(vec![
449                    Span::styled("   ", Style::default()),
450                    Span::styled(
451                        format!("   ... and {} more", remaining),
452                        theme.muted_text(),
453                    ),
454                ]));
455            }
456        }
457
458        // Separator line
459        lines.push(Line::from(""));
460
461        // Options
462        let options = self.available_options();
463        let request_count = self.batch.requests.len();
464
465        for (idx, option) in options.iter().enumerate() {
466            let is_selected = idx == self.selected_idx;
467            let prefix = if is_selected {
468                &self.config.selection_indicator
469            } else {
470                &self.config.no_indicator
471            };
472
473            let description = option.description(request_count, batch_type);
474
475            let (label_style, desc_style) = if is_selected {
476                if option.is_positive() {
477                    (theme.button_confirm_focused(), theme.focused_text())
478                } else {
479                    (theme.button_cancel_focused(), theme.focused_text())
480                }
481            } else if option.is_positive() {
482                (theme.button_confirm(), theme.muted_text())
483            } else {
484                (theme.button_cancel(), theme.muted_text())
485            };
486
487            let indicator_style = if is_selected {
488                theme.focus_indicator()
489            } else {
490                theme.muted_text()
491            };
492
493            lines.push(Line::from(vec![
494                Span::styled(prefix.clone(), indicator_style),
495                Span::styled(option.label(), label_style),
496                Span::styled(" - ", theme.muted_text()),
497                Span::styled(description, desc_style),
498            ]));
499        }
500
501        // Bottom padding
502        lines.push(Line::from(""));
503
504        // Build the block
505        let block = Block::default()
506            .borders(Borders::ALL)
507            .border_style(theme.warning())
508            .title(Span::styled(
509                self.config.title.clone(),
510                theme.warning().add_modifier(Modifier::BOLD),
511            ));
512
513        let paragraph = Paragraph::new(lines).block(block);
514        frame.render_widget(paragraph, area);
515    }
516}
517
518impl Default for BatchPermissionPanel {
519    fn default() -> Self {
520        Self::new()
521    }
522}
523
524/// Format a grant target for display
525fn format_target(target: &GrantTarget) -> (&'static str, String) {
526    match target {
527        GrantTarget::Path { path, recursive } => {
528            let rec_suffix = if *recursive { " (recursive)" } else { "" };
529            ("\u{1F4C4}", format!("{}{}", path.display(), rec_suffix))
530        }
531        GrantTarget::Domain { pattern } => ("\u{2194}", pattern.clone()),
532        GrantTarget::Command { pattern } => ("\u{2295}", pattern.clone()),
533    }
534}
535
536/// Format a permission level for display
537fn format_level(level: PermissionLevel) -> &'static str {
538    match level {
539        PermissionLevel::None => "None",
540        PermissionLevel::Read => "Read File",
541        PermissionLevel::Write => "Write File",
542        PermissionLevel::Execute => "Execute",
543        PermissionLevel::Admin => "Admin",
544    }
545}
546
547/// Truncate text to fit within a maximum width
548fn truncate_text(text: &str, max_width: usize) -> String {
549    if text.chars().count() <= max_width {
550        text.to_string()
551    } else {
552        let truncated: String = text.chars().take(max_width.saturating_sub(3)).collect();
553        format!("{}...", truncated)
554    }
555}
556
557// --- Widget trait implementation ---
558
559use super::{widget_ids, Widget, WidgetAction, WidgetKeyContext, WidgetKeyResult};
560use std::any::Any;
561
562impl Widget for BatchPermissionPanel {
563    fn id(&self) -> &'static str {
564        widget_ids::BATCH_PERMISSION_PANEL
565    }
566
567    fn priority(&self) -> u8 {
568        200 // High priority - modal panel
569    }
570
571    fn is_active(&self) -> bool {
572        self.active
573    }
574
575    fn handle_key(&mut self, key: KeyEvent, ctx: &WidgetKeyContext) -> WidgetKeyResult {
576        if !self.active {
577            return WidgetKeyResult::NotHandled;
578        }
579
580        // Use NavigationHelper for navigation keys
581        if ctx.nav.is_move_up(&key) {
582            self.select_prev();
583            return WidgetKeyResult::Handled;
584        }
585        if ctx.nav.is_move_down(&key) {
586            self.select_next();
587            return WidgetKeyResult::Handled;
588        }
589
590        // Selection using nav helper
591        if ctx.nav.is_select(&key) {
592            let option = self.selected_option();
593            let response = self.build_response(option);
594            return WidgetKeyResult::Action(WidgetAction::SubmitBatchPermission {
595                batch_id: self.batch_id.clone(),
596                response,
597            });
598        }
599
600        // Cancel using nav helper
601        if ctx.nav.is_cancel(&key) {
602            let batch_id = self.batch_id.clone();
603            return WidgetKeyResult::Action(WidgetAction::CancelBatchPermission { batch_id });
604        }
605
606        // j/k vim-style navigation (kept for consistency)
607        match key.code {
608            KeyCode::Char('k') => {
609                self.select_prev();
610                WidgetKeyResult::Handled
611            }
612            KeyCode::Char('j') => {
613                self.select_next();
614                WidgetKeyResult::Handled
615            }
616            _ => WidgetKeyResult::NotHandled,
617        }
618    }
619
620    fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) {
621        self.render_panel(frame, area, theme);
622    }
623
624    fn required_height(&self, max_height: u16) -> u16 {
625        if self.active {
626            self.panel_height(max_height)
627        } else {
628            0
629        }
630    }
631
632    fn blocks_input(&self) -> bool {
633        self.active
634    }
635
636    fn is_overlay(&self) -> bool {
637        false
638    }
639
640    fn as_any(&self) -> &dyn Any {
641        self
642    }
643
644    fn as_any_mut(&mut self) -> &mut dyn Any {
645        self
646    }
647
648    fn into_any(self: Box<Self>) -> Box<dyn Any> {
649        self
650    }
651}
652
653#[cfg(test)]
654mod tests {
655    use super::*;
656    use crate::permissions::PermissionRequest;
657
658    fn create_read_batch() -> BatchPermissionRequest {
659        let requests = vec![
660            PermissionRequest::file_read("1", "/project/src/main.rs"),
661            PermissionRequest::file_read("2", "/project/src/lib.rs"),
662            PermissionRequest::file_read("3", "/project/src/utils.rs"),
663        ];
664        BatchPermissionRequest::new("batch-1", requests)
665    }
666
667    fn create_write_batch() -> BatchPermissionRequest {
668        let requests = vec![
669            PermissionRequest::file_write("1", "/project/src/main.rs"),
670            PermissionRequest::file_write("2", "/project/src/lib.rs"),
671        ];
672        BatchPermissionRequest::new("batch-2", requests)
673    }
674
675    fn create_mixed_batch() -> BatchPermissionRequest {
676        let requests = vec![
677            PermissionRequest::file_read("1", "/project/src/main.rs"),
678            PermissionRequest::file_write("2", "/project/src/lib.rs"),
679        ];
680        BatchPermissionRequest::new("batch-3", requests)
681    }
682
683    fn create_command_batch() -> BatchPermissionRequest {
684        let requests = vec![
685            PermissionRequest::command_execute("1", "git status"),
686            PermissionRequest::command_execute("2", "git diff"),
687        ];
688        BatchPermissionRequest::new("batch-4", requests)
689    }
690
691    #[test]
692    fn test_panel_activation() {
693        let mut panel = BatchPermissionPanel::new();
694        assert!(!panel.is_active());
695
696        let batch = create_read_batch();
697        panel.activate(1, batch, None);
698
699        assert!(panel.is_active());
700        assert_eq!(panel.batch_id(), "batch-1");
701        assert_eq!(panel.session_id(), 1);
702        assert_eq!(panel.selected_idx, 0);
703
704        panel.deactivate();
705        assert!(!panel.is_active());
706    }
707
708    #[test]
709    fn test_homogeneous_read_batch() {
710        let mut panel = BatchPermissionPanel::new();
711        let batch = create_read_batch();
712        panel.activate(1, batch, None);
713
714        assert_eq!(panel.homogeneous_file_level(), Some(PermissionLevel::Read));
715        assert_eq!(panel.available_options().len(), 4); // Includes GrantAllSimilar
716    }
717
718    #[test]
719    fn test_homogeneous_write_batch() {
720        let mut panel = BatchPermissionPanel::new();
721        let batch = create_write_batch();
722        panel.activate(1, batch, None);
723
724        assert_eq!(
725            panel.homogeneous_file_level(),
726            Some(PermissionLevel::Write)
727        );
728        assert_eq!(panel.available_options().len(), 4); // Includes GrantAllSimilar
729    }
730
731    #[test]
732    fn test_mixed_batch_no_grant_all() {
733        let mut panel = BatchPermissionPanel::new();
734        let batch = create_mixed_batch();
735        panel.activate(1, batch, None);
736
737        assert_eq!(panel.homogeneous_file_level(), None);
738        assert_eq!(panel.available_options().len(), 3); // No GrantAllSimilar
739    }
740
741    #[test]
742    fn test_command_batch_no_grant_all() {
743        let mut panel = BatchPermissionPanel::new();
744        let batch = create_command_batch();
745        panel.activate(1, batch, None);
746
747        assert_eq!(panel.homogeneous_file_level(), None);
748        assert_eq!(panel.available_options().len(), 3); // No GrantAllSimilar for commands
749    }
750
751    #[test]
752    fn test_navigation() {
753        let mut panel = BatchPermissionPanel::new();
754        let batch = create_read_batch();
755        panel.activate(1, batch, None);
756
757        // Default is first option
758        assert_eq!(panel.selected_option(), BatchPermissionOption::GrantOnce);
759
760        // Move down
761        panel.select_next();
762        assert_eq!(
763            panel.selected_option(),
764            BatchPermissionOption::GrantSession
765        );
766
767        panel.select_next();
768        assert_eq!(
769            panel.selected_option(),
770            BatchPermissionOption::GrantAllSimilar
771        );
772
773        panel.select_next();
774        assert_eq!(panel.selected_option(), BatchPermissionOption::Deny);
775
776        // Wrap around
777        panel.select_next();
778        assert_eq!(panel.selected_option(), BatchPermissionOption::GrantOnce);
779
780        // Move up (wrap)
781        panel.select_prev();
782        assert_eq!(panel.selected_option(), BatchPermissionOption::Deny);
783    }
784
785    #[test]
786    fn test_grant_once_response() {
787        let mut panel = BatchPermissionPanel::new();
788        let batch = create_read_batch();
789        panel.activate(1, batch, None);
790
791        let response = panel.build_response(BatchPermissionOption::GrantOnce);
792        assert_eq!(response.batch_id, "batch-1");
793        assert_eq!(response.approved_grants.len(), 3); // One grant per request
794        assert!(response.denied_requests.is_empty());
795    }
796
797    #[test]
798    fn test_grant_session_response() {
799        let mut panel = BatchPermissionPanel::new();
800        let batch = create_read_batch();
801        panel.activate(1, batch, None);
802
803        let response = panel.build_response(BatchPermissionOption::GrantSession);
804        assert_eq!(response.batch_id, "batch-1");
805        assert!(!response.approved_grants.is_empty()); // Uses suggested grants
806        assert!(response.denied_requests.is_empty());
807    }
808
809    #[test]
810    fn test_grant_all_similar_response() {
811        let mut panel = BatchPermissionPanel::new();
812        let batch = create_read_batch();
813        panel.activate(1, batch, None);
814
815        let response = panel.build_response(BatchPermissionOption::GrantAllSimilar);
816        assert_eq!(response.batch_id, "batch-1");
817        assert_eq!(response.approved_grants.len(), 1); // Single broad grant
818        assert!(response.denied_requests.is_empty());
819
820        // Check that it's a broad grant
821        let grant = &response.approved_grants[0];
822        assert_eq!(grant.level, PermissionLevel::Read);
823        if let GrantTarget::Path { path, recursive } = &grant.target {
824            assert_eq!(path.to_str().unwrap(), "/");
825            assert!(*recursive);
826        } else {
827            panic!("Expected path target");
828        }
829    }
830
831    #[test]
832    fn test_deny_response() {
833        let mut panel = BatchPermissionPanel::new();
834        let batch = create_read_batch();
835        panel.activate(1, batch, None);
836
837        let response = panel.build_response(BatchPermissionOption::Deny);
838        assert_eq!(response.batch_id, "batch-1");
839        assert!(response.approved_grants.is_empty());
840        assert_eq!(response.denied_requests.len(), 3);
841    }
842
843    #[test]
844    fn test_option_descriptions() {
845        let option = BatchPermissionOption::GrantOnce;
846        assert_eq!(
847            option.description(3, Some(PermissionLevel::Read)),
848            "Allow only these 3 requests"
849        );
850
851        let option = BatchPermissionOption::GrantSession;
852        assert_eq!(
853            option.description(3, Some(PermissionLevel::Read)),
854            "Allow reading these 3 files"
855        );
856        assert_eq!(
857            option.description(2, Some(PermissionLevel::Write)),
858            "Allow writing these 2 files"
859        );
860        assert_eq!(
861            option.description(5, None),
862            "Allow these for the session"
863        );
864
865        let option = BatchPermissionOption::GrantAllSimilar;
866        assert_eq!(
867            option.description(3, Some(PermissionLevel::Read)),
868            "Allow reading any file"
869        );
870        assert_eq!(
871            option.description(2, Some(PermissionLevel::Write)),
872            "Allow writing any file"
873        );
874
875        let option = BatchPermissionOption::Deny;
876        assert_eq!(option.description(3, None), "Deny all requests");
877    }
878}