1use 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
27pub mod defaults {
29 pub const MAX_PANEL_PERCENT: u16 = 70;
31 pub const SELECTION_INDICATOR: &str = " \u{203A} ";
33 pub const NO_INDICATOR: &str = " ";
35 pub const TITLE: &str = " Permission Request ";
37 pub const HELP_TEXT: &str =
39 " Up/Down: Navigate \u{00B7} Enter/Space: Select \u{00B7} Esc: Cancel";
40}
41
42#[derive(Clone)]
44pub struct BatchPermissionPanelConfig {
45 pub max_panel_percent: u16,
47 pub selection_indicator: String,
49 pub no_indicator: String,
51 pub title: String,
53 pub help_text: String,
55}
56
57impl Default for BatchPermissionPanelConfig {
58 fn default() -> Self {
59 Self::new()
60 }
61}
62
63impl BatchPermissionPanelConfig {
64 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum BatchPermissionOption {
79 GrantOnce,
81 GrantSession,
83 GrantAllSimilar,
86 Deny,
88}
89
90impl BatchPermissionOption {
91 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 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 pub fn is_positive(&self) -> bool {
123 !matches!(self, BatchPermissionOption::Deny)
124 }
125}
126
127#[derive(Debug, Clone)]
129pub enum BatchKeyAction {
130 None,
132 Submitted(BatchPermissionResponse),
134 Cancelled(String),
136}
137
138pub struct BatchPermissionPanel {
140 active: bool,
142 batch_id: String,
144 session_id: i64,
146 batch: BatchPermissionRequest,
148 turn_id: Option<TurnId>,
150 selected_idx: usize,
152 config: BatchPermissionPanelConfig,
154}
155
156impl BatchPermissionPanel {
157 pub fn new() -> Self {
159 Self::with_config(BatchPermissionPanelConfig::new())
160 }
161
162 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 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; }
189
190 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 pub fn is_active(&self) -> bool {
201 self.active
202 }
203
204 pub fn batch_id(&self) -> &str {
206 &self.batch_id
207 }
208
209 pub fn session_id(&self) -> i64 {
211 self.session_id
212 }
213
214 pub fn batch(&self) -> &BatchPermissionRequest {
216 &self.batch
217 }
218
219 pub fn turn_id(&self) -> Option<&TurnId> {
221 self.turn_id.as_ref()
222 }
223
224 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 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 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 fn available_options(&self) -> Vec<BatchPermissionOption> {
266 match self.homogeneous_file_level() {
267 Some(PermissionLevel::Read) | Some(PermissionLevel::Write) => {
268 vec![
270 BatchPermissionOption::GrantOnce,
271 BatchPermissionOption::GrantSession,
272 BatchPermissionOption::GrantAllSimilar,
273 BatchPermissionOption::Deny,
274 ]
275 }
276 _ => {
277 vec![
279 BatchPermissionOption::GrantOnce,
280 BatchPermissionOption::GrantSession,
281 BatchPermissionOption::Deny,
282 ]
283 }
284 }
285 }
286
287 pub fn selected_option(&self) -> BatchPermissionOption {
289 let options = self.available_options();
290 options[self.selected_idx.min(options.len() - 1)]
291 }
292
293 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 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 fn build_response(&self, option: BatchPermissionOption) -> BatchPermissionResponse {
311 match option {
312 BatchPermissionOption::GrantOnce => {
313 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 BatchPermissionResponse::all_granted(
329 &self.batch_id,
330 self.batch.suggested_grants.clone(),
331 )
332 }
333 BatchPermissionOption::GrantAllSimilar => {
334 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 pub fn process_key(&mut self, key: KeyEvent) -> BatchKeyAction {
349 if !self.active {
350 return BatchKeyAction::None;
351 }
352
353 match key.code {
354 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 KeyCode::Enter | KeyCode::Char(' ') => {
366 let option = self.selected_option();
367 let response = self.build_response(option);
368 BatchKeyAction::Submitted(response)
369 }
370
371 KeyCode::Esc => {
373 let batch_id = self.batch_id.clone();
374 BatchKeyAction::Cancelled(batch_id)
375 }
376
377 _ => BatchKeyAction::None,
378 }
379 }
380
381 pub fn panel_height(&self, max_height: u16) -> u16 {
383 let mut lines = 6u16; lines += self.batch.requests.len().min(10) as u16; lines += self.available_options().len() as u16;
396 lines += 2; 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 pub fn render_panel(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
404 if !self.active {
405 return;
406 }
407
408 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 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("")); lines.push(Line::from(Span::styled(
424 " Permissions requested:",
425 theme.muted_text().add_modifier(Modifier::BOLD),
426 )));
427 lines.push(Line::from("")); let max_requests = 10; 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 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 lines.push(Line::from(""));
460
461 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 lines.push(Line::from(""));
503
504 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
524fn 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
536fn 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
547fn 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
557use 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 }
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 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 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 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 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); }
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); }
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); }
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); }
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 assert_eq!(panel.selected_option(), BatchPermissionOption::GrantOnce);
759
760 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 panel.select_next();
778 assert_eq!(panel.selected_option(), BatchPermissionOption::GrantOnce);
779
780 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); 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()); 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); assert!(response.denied_requests.is_empty());
819
820 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}