Skip to main content

ward/cli/
tui.rs

1use std::io;
2use std::time::Duration;
3
4use anyhow::Result;
5use crossterm::{
6    ExecutableCommand,
7    event::{self, Event, KeyCode, KeyEventKind},
8    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
9};
10use ratatui::{
11    Frame, Terminal,
12    layout::{Constraint, Direction, Layout, Rect},
13    prelude::CrosstermBackend,
14    style::{Color, Modifier, Style, Stylize},
15    text::{Line, Span},
16    widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs, Wrap},
17};
18use tokio::sync::mpsc;
19
20use crate::config::manifest::BranchProtectionConfig;
21use crate::config::templates::load_templates_with_custom_dir;
22use crate::config::{Manifest, SecurityConfig};
23use crate::detection::project_type::ProjectType;
24use crate::github::Client;
25use crate::github::commits::CommitFile;
26use crate::github::repos::Repository;
27use crate::github::security::SecurityState;
28
29const SPINNER_FRAMES: [char; 4] = ['|', '/', '-', '\\'];
30
31enum Tab {
32    Repos,
33    Security,
34    Actions,
35    Help,
36}
37
38enum BgMessage {
39    ReposLoaded(Vec<RepoEntry>),
40    SecurityLoaded(usize, SecurityState),
41    SecurityApplied(String, std::result::Result<(), String>),
42    ProtectionApplied(String, std::result::Result<(), String>),
43    TemplateDeployed(String, String, std::result::Result<String, String>),
44    SettingsApplied(String, std::result::Result<String, String>),
45    Error(String),
46}
47
48enum PendingAction {
49    ApplySecurity(String),
50    ApplyProtection(String),
51    SelectTemplate(String),
52    DeployTemplate(String, String),
53    ApplySettings(String),
54    BulkApplySecurity,
55}
56
57struct App {
58    tab: Tab,
59    repos: Vec<RepoEntry>,
60    list_state: ListState,
61    filter: String,
62    is_filtering: bool,
63    should_quit: bool,
64    status_msg: String,
65    systems: Vec<(String, String)>,
66    selected_system: usize,
67    loading: bool,
68    spinner_frame: usize,
69    pending_confirm: Option<PendingAction>,
70    cache: std::collections::HashMap<String, Vec<RepoEntry>>,
71    bulk_progress: Option<(usize, usize)>,
72}
73
74#[derive(Clone)]
75struct RepoEntry {
76    repo: Repository,
77    security: Option<SecurityState>,
78}
79
80impl App {
81    fn new(systems: Vec<(String, String)>) -> Self {
82        let mut state = ListState::default();
83        state.select(Some(0));
84        Self {
85            tab: Tab::Repos,
86            repos: Vec::new(),
87            list_state: state,
88            filter: String::new(),
89            is_filtering: false,
90            should_quit: false,
91            status_msg: "Press Enter to load repos".to_owned(),
92            systems,
93            selected_system: 0,
94            loading: false,
95            spinner_frame: 0,
96            pending_confirm: None,
97            cache: std::collections::HashMap::new(),
98            bulk_progress: None,
99        }
100    }
101
102    fn filtered_repos(&self) -> Vec<&RepoEntry> {
103        if self.filter.is_empty() {
104            return self.repos.iter().collect();
105        }
106
107        let terms: Vec<&str> = self.filter.split_whitespace().collect();
108        let includes: Vec<&str> = terms
109            .iter()
110            .filter(|t| !t.starts_with('!'))
111            .copied()
112            .collect();
113        let excludes: Vec<String> = terms
114            .iter()
115            .filter(|t| t.starts_with('!'))
116            .map(|t| t[1..].to_lowercase())
117            .collect();
118
119        self.repos
120            .iter()
121            .filter(|r| {
122                let name = r.repo.name.to_lowercase();
123                let include_ok = includes.is_empty()
124                    || includes
125                        .iter()
126                        .any(|inc| name.contains(&inc.to_lowercase()));
127                let exclude_ok = excludes.iter().all(|exc| !name.contains(exc.as_str()));
128                include_ok && exclude_ok
129            })
130            .collect()
131    }
132
133    fn selected_repo(&self) -> Option<&RepoEntry> {
134        let filtered = self.filtered_repos();
135        self.list_state
136            .selected()
137            .and_then(|i| filtered.get(i).copied())
138    }
139
140    fn spinner_char(&self) -> char {
141        SPINNER_FRAMES[self.spinner_frame % SPINNER_FRAMES.len()]
142    }
143
144    fn is_loading_security(&self) -> bool {
145        !self.repos.is_empty() && self.repos.iter().any(|r| r.security.is_none())
146    }
147}
148
149pub async fn run(client: &Client, manifest: &Manifest) -> Result<()> {
150    let systems: Vec<(String, String)> = manifest
151        .systems
152        .iter()
153        .map(|s| (s.id.clone(), s.name.clone()))
154        .collect();
155
156    if systems.is_empty() {
157        anyhow::bail!("No systems defined in ward.toml - add [[systems]] entries first");
158    }
159
160    enable_raw_mode()?;
161    io::stdout().execute(EnterAlternateScreen)?;
162
163    let backend = CrosstermBackend::new(io::stdout());
164    let mut terminal = Terminal::new(backend)?;
165    let mut app = App::new(systems);
166
167    let result = run_loop(&mut terminal, &mut app, client, manifest).await;
168
169    disable_raw_mode()?;
170    io::stdout().execute(LeaveAlternateScreen)?;
171
172    result
173}
174
175fn spawn_repo_load(
176    tx: &mpsc::UnboundedSender<BgMessage>,
177    client: &Client,
178    manifest: &Manifest,
179    system_id: &str,
180) {
181    let tx = tx.clone();
182    let org = client.org.clone();
183    let http = client.http.clone();
184    let base_url = client.base_url.clone();
185    let semaphore = client.semaphore.clone();
186    let excludes = manifest.exclude_patterns_for_system(system_id);
187    let explicit = manifest.explicit_repos_for_system(system_id);
188    let sys_id = system_id.to_owned();
189
190    tokio::spawn(async move {
191        let bg_client = Client {
192            http,
193            org,
194            semaphore,
195            base_url,
196        };
197
198        match bg_client
199            .list_repos_for_system(&sys_id, &excludes, &explicit)
200            .await
201        {
202            Ok(repos) => {
203                let entries: Vec<RepoEntry> = repos
204                    .into_iter()
205                    .map(|repo| RepoEntry {
206                        repo,
207                        security: None,
208                    })
209                    .collect();
210                let _ = tx.send(BgMessage::ReposLoaded(entries));
211            }
212            Err(e) => {
213                let _ = tx.send(BgMessage::Error(e.to_string()));
214            }
215        }
216    });
217}
218
219fn spawn_security_load(
220    tx: &mpsc::UnboundedSender<BgMessage>,
221    client: &Client,
222    repos: &[RepoEntry],
223) {
224    for (idx, entry) in repos.iter().enumerate() {
225        let tx = tx.clone();
226        let http = client.http.clone();
227        let org = client.org.clone();
228        let base_url = client.base_url.clone();
229        let semaphore = client.semaphore.clone();
230        let repo_name = entry.repo.name.clone();
231
232        tokio::spawn(async move {
233            let bg_client = Client {
234                http,
235                org,
236                semaphore,
237                base_url,
238            };
239
240            if let Ok(state) = bg_client.get_security_state(&repo_name).await {
241                let _ = tx.send(BgMessage::SecurityLoaded(idx, state));
242            }
243        });
244    }
245}
246
247fn spawn_security_apply(
248    tx: &mpsc::UnboundedSender<BgMessage>,
249    client: &Client,
250    repo_name: &str,
251    config: &SecurityConfig,
252) {
253    let tx = tx.clone();
254    let http = client.http.clone();
255    let org = client.org.clone();
256    let base_url = client.base_url.clone();
257    let semaphore = client.semaphore.clone();
258    let repo = repo_name.to_owned();
259    let cfg = config.clone();
260
261    tokio::spawn(async move {
262        let bg_client = Client {
263            http,
264            org,
265            semaphore,
266            base_url,
267        };
268
269        let result = async {
270            if cfg.dependabot_alerts {
271                bg_client.enable_dependabot_alerts(&repo).await?;
272            }
273            if cfg.dependabot_security_updates {
274                bg_client.enable_dependabot_security_updates(&repo).await?;
275            }
276            bg_client
277                .set_security_features(
278                    &repo,
279                    cfg.secret_scanning,
280                    cfg.secret_scanning_ai_detection,
281                    cfg.push_protection,
282                )
283                .await?;
284            Ok::<(), anyhow::Error>(())
285        }
286        .await;
287
288        let msg = match result {
289            Ok(()) => BgMessage::SecurityApplied(repo, Ok(())),
290            Err(e) => BgMessage::SecurityApplied(repo, Err(e.to_string())),
291        };
292        let _ = tx.send(msg);
293    });
294}
295
296fn spawn_protection_apply(
297    tx: &mpsc::UnboundedSender<BgMessage>,
298    client: &Client,
299    repo: &str,
300    branch: &str,
301    config: &BranchProtectionConfig,
302) {
303    let tx = tx.clone();
304    let http = client.http.clone();
305    let org = client.org.clone();
306    let base_url = client.base_url.clone();
307    let semaphore = client.semaphore.clone();
308    let repo = repo.to_owned();
309    let branch = branch.to_owned();
310    let cfg = config.clone();
311
312    tokio::spawn(async move {
313        let bg_client = Client {
314            http,
315            org,
316            semaphore,
317            base_url,
318        };
319
320        let result = bg_client
321            .update_branch_protection(&repo, &branch, &cfg)
322            .await;
323
324        let msg = match result {
325            Ok(()) => BgMessage::ProtectionApplied(repo, Ok(())),
326            Err(e) => BgMessage::ProtectionApplied(repo, Err(e.to_string())),
327        };
328        let _ = tx.send(msg);
329    });
330}
331
332fn spawn_template_deploy(
333    tx: &mpsc::UnboundedSender<BgMessage>,
334    client: &Client,
335    manifest: &Manifest,
336    repo_name: &str,
337    default_branch: &str,
338    template_name: &str,
339) {
340    let tx = tx.clone();
341    let http = client.http.clone();
342    let org = client.org.clone();
343    let base_url = client.base_url.clone();
344    let semaphore = client.semaphore.clone();
345    let repo = repo_name.to_owned();
346    let default_br = default_branch.to_owned();
347    let template = template_name.to_owned();
348    let branch_name = manifest.templates.branch.clone();
349    let reviewers = manifest.templates.reviewers.clone();
350    let commit_prefix = manifest.templates.commit_message_prefix.clone();
351    let custom_dir = manifest.templates.custom_dir.clone();
352    let registries = manifest.templates.registries.clone();
353
354    tokio::spawn(async move {
355        let bg_client = Client {
356            http,
357            org,
358            semaphore,
359            base_url,
360        };
361
362        let result = async {
363            let (target_path, template_category) = match template.as_str() {
364                "dependabot" => (".github/dependabot.yml", "dependabot"),
365                "codeql" => (".github/workflows/codeql.yml", "codeql"),
366                "dependency-submission" => (
367                    ".github/workflows/dependency-submission.yml",
368                    "dependency-submission",
369                ),
370                _ => return Err(format!("Unknown template: {template}")),
371            };
372
373            // Detect project type
374            let project_type = detect_project_type_bg(&bg_client, &repo)
375                .await
376                .map_err(|e| format!("Detection failed: {e}"))?;
377
378            let tera_template_name = match (&project_type, template_category) {
379                (ProjectType::Gradle, "dependabot") => "dependabot/gradle.yml.tera",
380                (ProjectType::Npm, "dependabot") => "dependabot/npm.yml.tera",
381                (ProjectType::Gradle, "codeql") => "codeql/gradle.yml.tera",
382                (ProjectType::Npm, "codeql") => "codeql/npm.yml.tera",
383                (ProjectType::Gradle, "dependency-submission") => {
384                    "dependency-submission/gradle.yml.tera"
385                }
386                (pt, cat) => {
387                    return Err(format!("No template for {cat} + {pt} in {repo}"));
388                }
389            };
390
391            let mut ctx = tera::Context::new();
392            ctx.insert("default_branch", &default_br);
393
394            match project_type {
395                ProjectType::Gradle => {
396                    let java_ver = detect_java_version_bg(&bg_client, &repo)
397                        .await
398                        .map_err(|e| e.to_string())?;
399                    ctx.insert("java_version", &java_ver.to_string());
400                    if let Some(reg) = registries.get("gradle-artifactory") {
401                        ctx.insert("registry_url", &reg.url);
402                        if let Some(ref provider) = reg.jfrog_oidc_provider {
403                            ctx.insert("jfrog_oidc_provider", provider);
404                        }
405                    }
406                }
407                ProjectType::Npm => {
408                    let node_ver = detect_node_version_bg(&bg_client, &repo)
409                        .await
410                        .map_err(|e| e.to_string())?;
411                    ctx.insert("node_version", &node_ver);
412                }
413                _ => {}
414            }
415
416            let tera =
417                load_templates_with_custom_dir(custom_dir.as_deref().map(std::path::Path::new))
418                    .map_err(|e| format!("Template load error: {e}"))?;
419            let rendered = tera
420                .render(tera_template_name, &ctx)
421                .map_err(|e| format!("Render error: {e}"))?;
422
423            // Check if file already exists and matches
424            if let Ok(Some(existing)) = bg_client.get_file(&repo, target_path, None).await
425                && let Ok(decoded) = Client::decode_content(&existing)
426                && decoded.trim() == rendered.trim()
427            {
428                return Err(format!("{repo}: already up to date"));
429            }
430
431            bg_client
432                .create_branch(&repo, &branch_name, &default_br)
433                .await
434                .map_err(|e| format!("Branch error: {e}"))?;
435
436            let message = format!("{commit_prefix}add {template} configuration");
437            let files = vec![CommitFile {
438                path: target_path.to_owned(),
439                content: rendered,
440            }];
441            bg_client
442                .create_commit(&repo, &branch_name, &message, &files)
443                .await
444                .map_err(|e| format!("Commit error: {e}"))?;
445
446            let pr_title = format!("{commit_prefix}add {template} configuration");
447            let pr_body = format!(
448                "## Ward: automated template commit\n\n\
449                 Template: `{template}`\nFile: `{target_path}`\n\n\
450                 This PR was created by [ward](https://github.com/OriginalMHV/ward).\n\n\
451                 ---\n*Review the file contents, then merge.*"
452            );
453            let pr = bg_client
454                .create_pull_request(
455                    &repo,
456                    &pr_title,
457                    &pr_body,
458                    &branch_name,
459                    &default_br,
460                    &reviewers,
461                )
462                .await
463                .map_err(|e| format!("PR error: {e}"))?;
464
465            Ok(pr.html_url)
466        }
467        .await;
468
469        let _ = tx.send(BgMessage::TemplateDeployed(repo, template, result));
470    });
471}
472
473fn spawn_settings_apply(
474    tx: &mpsc::UnboundedSender<BgMessage>,
475    client: &Client,
476    manifest: &Manifest,
477    repo_name: &str,
478    default_branch: &str,
479) {
480    let tx = tx.clone();
481    let http = client.http.clone();
482    let org = client.org.clone();
483    let base_url = client.base_url.clone();
484    let semaphore = client.semaphore.clone();
485    let repo = repo_name.to_owned();
486    let default_br = default_branch.to_owned();
487    let branch_name = manifest.templates.branch.clone();
488    let reviewers = manifest.templates.reviewers.clone();
489    let commit_prefix = manifest.templates.commit_message_prefix.clone();
490    let custom_dir = manifest.templates.custom_dir.clone();
491    let is_ops = repo.ends_with("-operation")
492        || repo.ends_with("-operations")
493        || repo.ends_with("-ops")
494        || repo.ends_with("-gitops");
495
496    tokio::spawn(async move {
497        let bg_client = Client {
498            http,
499            org,
500            semaphore,
501            base_url,
502        };
503
504        let result = async {
505            let mut actions: Vec<String> = Vec::new();
506
507            // Check and create copilot review ruleset
508            let rulesets = bg_client
509                .list_rulesets(&repo)
510                .await
511                .map_err(|e| format!("List rulesets: {e}"))?;
512            let has_copilot_review = rulesets.iter().any(|r| r.name == "Copilot Code Review");
513            if !has_copilot_review {
514                bg_client
515                    .create_copilot_review_ruleset(&repo)
516                    .await
517                    .map_err(|e| format!("Create ruleset: {e}"))?;
518                actions.push("ruleset created".to_owned());
519            }
520
521            // Check and deploy copilot instructions
522            let has_instructions = bg_client
523                .get_file(&repo, ".github/copilot-instructions.md", None)
524                .await
525                .map_err(|e| format!("Check instructions: {e}"))?
526                .is_some();
527
528            if !has_instructions {
529                let template_name = if is_ops {
530                    "copilot-review/instructions-ops.md.tera"
531                } else {
532                    "copilot-review/instructions-app.md.tera"
533                };
534
535                let tera =
536                    load_templates_with_custom_dir(custom_dir.as_deref().map(std::path::Path::new))
537                        .map_err(|e| format!("Template load: {e}"))?;
538                let rendered = tera
539                    .render(template_name, &tera::Context::new())
540                    .map_err(|e| format!("Render: {e}"))?;
541
542                bg_client
543                    .create_branch(&repo, &branch_name, &default_br)
544                    .await
545                    .map_err(|e| format!("Branch: {e}"))?;
546
547                let files = vec![CommitFile {
548                    path: ".github/copilot-instructions.md".to_owned(),
549                    content: rendered,
550                }];
551                bg_client
552                    .create_commit(
553                        &repo,
554                        &branch_name,
555                        &format!("{commit_prefix}add Copilot review instructions"),
556                        &files,
557                    )
558                    .await
559                    .map_err(|e| format!("Commit: {e}"))?;
560
561                let pr = bg_client
562                    .create_pull_request(
563                        &repo,
564                        &format!("{commit_prefix}add Copilot review instructions"),
565                        "## Ward: Copilot review instructions\n\n\
566                         Deploys `.github/copilot-instructions.md` for Copilot code review.\n\n\
567                         ---\n*Review the instructions, then merge.*",
568                        &branch_name,
569                        &default_br,
570                        &reviewers,
571                    )
572                    .await
573                    .map_err(|e| format!("PR: {e}"))?;
574                actions.push(format!("instructions PR: {}", pr.html_url));
575            }
576
577            if actions.is_empty() {
578                Ok("already up to date".to_owned())
579            } else {
580                Ok(actions.join("; "))
581            }
582        }
583        .await;
584
585        let _ = tx.send(BgMessage::SettingsApplied(repo, result));
586    });
587}
588
589async fn detect_project_type_bg(client: &Client, repo: &str) -> Result<ProjectType> {
590    if client
591        .get_file(repo, "build.gradle.kts", None)
592        .await?
593        .is_some()
594    {
595        return Ok(ProjectType::Gradle);
596    }
597    if client.get_file(repo, "build.gradle", None).await?.is_some() {
598        return Ok(ProjectType::Gradle);
599    }
600    if client.get_file(repo, "package.json", None).await?.is_some() {
601        return Ok(ProjectType::Npm);
602    }
603    if client.get_file(repo, "Cargo.toml", None).await?.is_some() {
604        return Ok(ProjectType::Cargo);
605    }
606    Ok(ProjectType::Unknown)
607}
608
609async fn detect_java_version_bg(client: &Client, repo: &str) -> Result<u8> {
610    for file in &["build.gradle.kts", "build.gradle"] {
611        if let Some(content) = client.get_file(repo, file, None).await? {
612            let text = Client::decode_content(&content)?;
613            if let Some(ver) = crate::detection::versions::extract_java_version(&text) {
614                return Ok(ver);
615            }
616        }
617    }
618    Ok(21)
619}
620
621async fn detect_node_version_bg(client: &Client, repo: &str) -> Result<String> {
622    if let Some(content) = client.get_file(repo, "package.json", None).await? {
623        let text = Client::decode_content(&content)?;
624        if let Some(ver) = crate::detection::versions::extract_node_version(&text) {
625            let major: String = ver.chars().filter(|c| c.is_ascii_digit()).collect();
626            if !major.is_empty() {
627                return Ok(major);
628            }
629        }
630    }
631    Ok("20".to_owned())
632}
633
634fn switch_to_cached_or_prompt(app: &mut App) {
635    let (sys_id, sys_name) = &app.systems[app.selected_system];
636    if let Some(cached) = app.cache.get(sys_id) {
637        app.repos = cached.clone();
638        app.list_state.select(Some(0));
639        app.status_msg = format!("{} repos for {sys_name} (cached)", app.repos.len());
640    } else {
641        app.repos.clear();
642        app.list_state.select(Some(0));
643        app.status_msg = format!("{sys_name} ({sys_id}). Press Enter to load.");
644    }
645}
646
647async fn run_loop(
648    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
649    app: &mut App,
650    client: &Client,
651    manifest: &Manifest,
652) -> Result<()> {
653    let (tx, mut rx) = mpsc::unbounded_channel::<BgMessage>();
654
655    loop {
656        // Advance spinner each render cycle when loading
657        if app.loading || app.is_loading_security() {
658            app.spinner_frame = app.spinner_frame.wrapping_add(1);
659        }
660
661        terminal.draw(|f| draw(f, app))?;
662
663        // Check for background task results (non-blocking)
664        while let Ok(msg) = rx.try_recv() {
665            match msg {
666                BgMessage::ReposLoaded(entries) => {
667                    let count = entries.len();
668                    let (sys_id, sys_name) = &app.systems[app.selected_system];
669                    app.status_msg =
670                        format!("Loaded {count} repos for {sys_name}. Fetching security...");
671                    app.repos = entries;
672                    app.list_state.select(Some(0));
673                    app.loading = false;
674                    app.cache.insert(sys_id.clone(), app.repos.clone());
675                    spawn_security_load(&tx, client, &app.repos);
676                }
677                BgMessage::SecurityLoaded(idx, state) => {
678                    if let Some(entry) = app.repos.get_mut(idx) {
679                        entry.security = Some(state);
680                    }
681                    let loaded = app.repos.iter().filter(|r| r.security.is_some()).count();
682                    let total = app.repos.len();
683                    if loaded == total {
684                        let (sys_id, sys_name) = &app.systems[app.selected_system];
685                        app.status_msg = format!("{total} repos loaded for {sys_name}");
686                        app.cache.insert(sys_id.clone(), app.repos.clone());
687                    } else {
688                        app.status_msg =
689                            format!("[{}] Security: {loaded}/{total}...", app.spinner_char());
690                    }
691                }
692                BgMessage::SecurityApplied(repo, result) => {
693                    let spinner = app.spinner_char();
694                    match result {
695                        Ok(()) => {
696                            app.status_msg = format!("Applied security to {repo}");
697                            if let Some((ref mut done, total)) = app.bulk_progress {
698                                *done += 1;
699                                if *done >= total {
700                                    app.status_msg =
701                                        format!("Bulk security complete: {total}/{total}");
702                                    app.bulk_progress = None;
703                                    app.loading = false;
704                                } else {
705                                    app.status_msg =
706                                        format!("[{spinner}] Bulk security: {done}/{total}...");
707                                }
708                            } else {
709                                app.loading = false;
710                            }
711                        }
712                        Err(e) => {
713                            app.status_msg = format!("Failed to apply to {repo}: {e}");
714                            if let Some((ref mut done, total)) = app.bulk_progress {
715                                *done += 1;
716                                if *done >= total {
717                                    app.status_msg =
718                                        format!("Bulk security done ({total}), last error: {e}");
719                                    app.bulk_progress = None;
720                                    app.loading = false;
721                                }
722                            } else {
723                                app.loading = false;
724                            }
725                        }
726                    }
727                }
728                BgMessage::ProtectionApplied(repo, result) => {
729                    match result {
730                        Ok(()) => {
731                            app.status_msg = format!("Applied branch protection to {repo}");
732                        }
733                        Err(e) => {
734                            app.status_msg = format!("Failed branch protection on {repo}: {e}");
735                        }
736                    }
737                    app.loading = false;
738                }
739                BgMessage::TemplateDeployed(repo, template, result) => {
740                    match result {
741                        Ok(pr_url) => {
742                            app.status_msg = format!("Deployed {template} to {repo}: {pr_url}");
743                        }
744                        Err(e) => {
745                            app.status_msg = format!("Template {template} failed on {repo}: {e}");
746                        }
747                    }
748                    app.loading = false;
749                }
750                BgMessage::SettingsApplied(repo, result) => {
751                    match result {
752                        Ok(detail) => {
753                            app.status_msg = format!("Settings applied to {repo}: {detail}");
754                        }
755                        Err(e) => {
756                            app.status_msg = format!("Settings failed on {repo}: {e}");
757                        }
758                    }
759                    app.loading = false;
760                }
761                BgMessage::Error(e) => {
762                    app.status_msg = format!("Error: {e}");
763                    app.loading = false;
764                }
765            }
766        }
767
768        if event::poll(Duration::from_millis(50))?
769            && let Event::Key(key) = event::read()?
770        {
771            if key.kind != KeyEventKind::Press {
772                continue;
773            }
774
775            // Confirmation mode: only y/n accepted (and template sub-menu keys)
776            if app.pending_confirm.is_some() {
777                match key.code {
778                    KeyCode::Char('y') => {
779                        let action = app.pending_confirm.take();
780                        match action {
781                            Some(PendingAction::ApplySecurity(repo)) => {
782                                let sys_id = &app.systems[app.selected_system].0;
783                                let sec_config = manifest.security_for_system(sys_id).clone();
784                                app.loading = true;
785                                app.status_msg = format!(
786                                    "[{}] Applying security to {repo}...",
787                                    app.spinner_char()
788                                );
789                                spawn_security_apply(&tx, client, &repo, &sec_config);
790                            }
791                            Some(PendingAction::ApplyProtection(repo)) => {
792                                if let Some(entry) = app.repos.iter().find(|e| e.repo.name == repo)
793                                {
794                                    let branch = entry.repo.default_branch.clone();
795                                    let cfg = manifest.branch_protection.clone();
796                                    app.loading = true;
797                                    app.status_msg = format!(
798                                        "[{}] Applying protection to {repo}...",
799                                        app.spinner_char()
800                                    );
801                                    spawn_protection_apply(&tx, client, &repo, &branch, &cfg);
802                                }
803                            }
804                            Some(PendingAction::DeployTemplate(repo, template)) => {
805                                if let Some(entry) = app.repos.iter().find(|e| e.repo.name == repo)
806                                {
807                                    let default_br = entry.repo.default_branch.clone();
808                                    app.loading = true;
809                                    app.status_msg = format!(
810                                        "[{}] Deploying {template} to {repo}...",
811                                        app.spinner_char()
812                                    );
813                                    spawn_template_deploy(
814                                        &tx,
815                                        client,
816                                        manifest,
817                                        &repo,
818                                        &default_br,
819                                        &template,
820                                    );
821                                }
822                            }
823                            Some(PendingAction::ApplySettings(repo)) => {
824                                if let Some(entry) = app.repos.iter().find(|e| e.repo.name == repo)
825                                {
826                                    let default_br = entry.repo.default_branch.clone();
827                                    app.loading = true;
828                                    app.status_msg = format!(
829                                        "[{}] Applying settings to {repo}...",
830                                        app.spinner_char()
831                                    );
832                                    spawn_settings_apply(&tx, client, manifest, &repo, &default_br);
833                                }
834                            }
835                            Some(PendingAction::BulkApplySecurity) => {
836                                let filtered: Vec<String> = app
837                                    .filtered_repos()
838                                    .iter()
839                                    .map(|e| e.repo.name.clone())
840                                    .collect();
841                                let total = filtered.len();
842                                let sys_id = &app.systems[app.selected_system].0;
843                                let sec_config = manifest.security_for_system(sys_id).clone();
844                                app.loading = true;
845                                app.bulk_progress = Some((0, total));
846                                app.status_msg =
847                                    format!("[{}] Bulk security: 0/{total}...", app.spinner_char());
848                                for repo in &filtered {
849                                    spawn_security_apply(&tx, client, repo, &sec_config);
850                                }
851                            }
852                            Some(PendingAction::SelectTemplate(_)) | None => {}
853                        }
854                    }
855                    // Template sub-menu key handling
856                    KeyCode::Char('d') => {
857                        if let Some(PendingAction::SelectTemplate(repo)) =
858                            app.pending_confirm.take()
859                        {
860                            app.pending_confirm = Some(PendingAction::DeployTemplate(
861                                repo.clone(),
862                                "dependabot".to_owned(),
863                            ));
864                            app.status_msg = format!("Deploy dependabot to {repo}? (y/n)");
865                        }
866                    }
867                    KeyCode::Char('c') => {
868                        if let Some(PendingAction::SelectTemplate(repo)) =
869                            app.pending_confirm.take()
870                        {
871                            app.pending_confirm = Some(PendingAction::DeployTemplate(
872                                repo.clone(),
873                                "codeql".to_owned(),
874                            ));
875                            app.status_msg = format!("Deploy codeql to {repo}? (y/n)");
876                        }
877                    }
878                    KeyCode::Char('s') => {
879                        if let Some(PendingAction::SelectTemplate(repo)) =
880                            app.pending_confirm.take()
881                        {
882                            app.pending_confirm = Some(PendingAction::DeployTemplate(
883                                repo.clone(),
884                                "dependency-submission".to_owned(),
885                            ));
886                            app.status_msg =
887                                format!("Deploy dependency-submission to {repo}? (y/n)");
888                        }
889                    }
890                    KeyCode::Char('n') | KeyCode::Esc => {
891                        app.pending_confirm = None;
892                        app.status_msg = "Cancelled.".to_owned();
893                    }
894                    _ => {}
895                }
896                continue;
897            }
898
899            if app.is_filtering {
900                match key.code {
901                    KeyCode::Esc => {
902                        app.is_filtering = false;
903                        app.filter.clear();
904                    }
905                    KeyCode::Enter => {
906                        app.is_filtering = false;
907                    }
908                    KeyCode::Backspace => {
909                        app.filter.pop();
910                    }
911                    KeyCode::Char(c) => {
912                        app.filter.push(c);
913                        app.list_state.select(Some(0));
914                    }
915                    _ => {}
916                }
917                continue;
918            }
919
920            match key.code {
921                KeyCode::Char('q') => app.should_quit = true,
922                KeyCode::Char('1') => app.tab = Tab::Repos,
923                KeyCode::Char('2') => app.tab = Tab::Security,
924                KeyCode::Char('3') => app.tab = Tab::Actions,
925                KeyCode::Char('?') => app.tab = Tab::Help,
926                KeyCode::Char('/') => {
927                    app.is_filtering = true;
928                    app.filter.clear();
929                }
930                KeyCode::Char('a') => {
931                    if let Some(entry) = app.selected_repo() {
932                        let repo = entry.repo.name.clone();
933                        app.pending_confirm = Some(PendingAction::ApplySecurity(repo.clone()));
934                        app.status_msg = format!("Apply security to {repo}? (y/n)");
935                    }
936                }
937                KeyCode::Char('p') => {
938                    if let Some(entry) = app.selected_repo() {
939                        let repo = entry.repo.name.clone();
940                        app.pending_confirm = Some(PendingAction::ApplyProtection(repo.clone()));
941                        app.status_msg = format!("Apply branch protection to {repo}? (y/n)");
942                    }
943                }
944                KeyCode::Char('t') => {
945                    if let Some(entry) = app.selected_repo() {
946                        let repo = entry.repo.name.clone();
947                        app.pending_confirm = Some(PendingAction::SelectTemplate(repo));
948                        app.status_msg =
949                            "Deploy template: (d)ependabot (c)odeql (s)ubmission".to_owned();
950                    }
951                }
952                KeyCode::Char('S') => {
953                    if let Some(entry) = app.selected_repo() {
954                        let repo = entry.repo.name.clone();
955                        app.pending_confirm = Some(PendingAction::ApplySettings(repo.clone()));
956                        app.status_msg = format!(
957                            "Apply settings (copilot ruleset + instructions) to {repo}? (y/n)"
958                        );
959                    }
960                }
961                KeyCode::Char('A') => {
962                    let count = app.filtered_repos().len();
963                    if count > 0 {
964                        app.pending_confirm = Some(PendingAction::BulkApplySecurity);
965                        app.status_msg =
966                            format!("Apply security to all {count} filtered repos? (y/n)");
967                    }
968                }
969                KeyCode::Char('r') => {
970                    if !app.loading {
971                        app.loading = true;
972                        let sys_id = &app.systems[app.selected_system].0;
973                        app.status_msg = format!(
974                            "[{}] Loading {}...",
975                            app.spinner_char(),
976                            app.systems[app.selected_system].1
977                        );
978                        spawn_repo_load(&tx, client, manifest, sys_id);
979                    }
980                }
981                KeyCode::Char('R') => {
982                    if !app.loading {
983                        let sys_id = app.systems[app.selected_system].0.clone();
984                        app.cache.remove(&sys_id);
985                        app.loading = true;
986                        app.status_msg = format!(
987                            "[{}] Force loading {}...",
988                            app.spinner_char(),
989                            app.systems[app.selected_system].1
990                        );
991                        spawn_repo_load(&tx, client, manifest, &sys_id);
992                    }
993                }
994                KeyCode::Char('l') | KeyCode::Enter => {
995                    if !app.loading {
996                        app.loading = true;
997                        let sys_id = &app.systems[app.selected_system].0;
998                        app.status_msg = format!(
999                            "[{}] Loading {}...",
1000                            app.spinner_char(),
1001                            app.systems[app.selected_system].1
1002                        );
1003                        spawn_repo_load(&tx, client, manifest, sys_id);
1004                    }
1005                }
1006                KeyCode::Tab | KeyCode::Char('s') => {
1007                    if !app.systems.is_empty() {
1008                        app.selected_system = (app.selected_system + 1) % app.systems.len();
1009                        switch_to_cached_or_prompt(app);
1010                    }
1011                }
1012                KeyCode::BackTab => {
1013                    if !app.systems.is_empty() {
1014                        app.selected_system = if app.selected_system == 0 {
1015                            app.systems.len() - 1
1016                        } else {
1017                            app.selected_system - 1
1018                        };
1019                        switch_to_cached_or_prompt(app);
1020                    }
1021                }
1022                KeyCode::Down | KeyCode::Char('j') => {
1023                    let len = app.filtered_repos().len();
1024                    if len > 0 {
1025                        let i = app.list_state.selected().unwrap_or(0);
1026                        app.list_state.select(Some((i + 1).min(len - 1)));
1027                    }
1028                }
1029                KeyCode::Up | KeyCode::Char('k') => {
1030                    let i = app.list_state.selected().unwrap_or(0);
1031                    app.list_state.select(Some(i.saturating_sub(1)));
1032                }
1033                _ => {}
1034            }
1035        }
1036
1037        if app.should_quit {
1038            return Ok(());
1039        }
1040    }
1041}
1042
1043fn draw(f: &mut Frame, app: &App) {
1044    let chunks = Layout::default()
1045        .direction(Direction::Vertical)
1046        .constraints([
1047            Constraint::Length(3),
1048            Constraint::Min(10),
1049            Constraint::Length(3),
1050        ])
1051        .split(f.area());
1052
1053    draw_header(f, chunks[0], app);
1054
1055    match app.tab {
1056        Tab::Repos => draw_repos_tab(f, chunks[1], app),
1057        Tab::Security => draw_security_tab(f, chunks[1], app),
1058        Tab::Actions => draw_actions_tab(f, chunks[1]),
1059        Tab::Help => draw_help_tab(f, chunks[1]),
1060    }
1061
1062    draw_status(f, chunks[2], app);
1063}
1064
1065fn draw_header(f: &mut Frame, area: Rect, app: &App) {
1066    let titles = vec!["[1] Repos", "[2] Security", "[3] Actions", "[?] Help"];
1067    let selected = match app.tab {
1068        Tab::Repos => 0,
1069        Tab::Security => 1,
1070        Tab::Actions => 2,
1071        Tab::Help => 3,
1072    };
1073
1074    let tabs = Tabs::new(titles)
1075        .block(
1076            Block::default()
1077                .borders(Borders::BOTTOM)
1078                .title(" Ward: GitHub Repository Management "),
1079        )
1080        .select(selected)
1081        .style(Style::default().fg(Color::Gray))
1082        .highlight_style(Style::default().fg(Color::Cyan).bold());
1083
1084    f.render_widget(tabs, area);
1085}
1086
1087fn draw_repos_tab(f: &mut Frame, area: Rect, app: &App) {
1088    let chunks = Layout::default()
1089        .direction(Direction::Horizontal)
1090        .constraints([Constraint::Percentage(45), Constraint::Percentage(55)])
1091        .split(area);
1092
1093    let filtered = app.filtered_repos();
1094    let items: Vec<ListItem> = filtered
1095        .iter()
1096        .map(|entry| {
1097            let lang = entry.repo.language.as_deref().unwrap_or("-");
1098            let (indicator, color) = entry
1099                .security
1100                .as_ref()
1101                .map(|s| {
1102                    if s.dependabot_alerts && s.secret_scanning && s.push_protection {
1103                        ("[ok]", Color::Green)
1104                    } else {
1105                        ("[!!]", Color::Yellow)
1106                    }
1107                })
1108                .unwrap_or(("[..]", Color::DarkGray));
1109
1110            ListItem::new(Line::from(vec![
1111                Span::styled(indicator, Style::default().fg(color)),
1112                Span::raw(" "),
1113                Span::styled(entry.repo.name.clone(), Style::default().fg(Color::White)),
1114                Span::raw("  "),
1115                Span::styled(lang, Style::default().fg(Color::DarkGray)),
1116            ]))
1117        })
1118        .collect();
1119
1120    let title = if app.loading {
1121        format!(" Repos [{}] loading... ", app.spinner_char())
1122    } else if app.is_filtering {
1123        format!(" Repos (filter: {}_) ", app.filter)
1124    } else if app.filter.is_empty() {
1125        format!(" Repos ({}) ", filtered.len())
1126    } else {
1127        format!(" Repos ({}/{}) ", filtered.len(), app.repos.len())
1128    };
1129
1130    let list = List::new(items)
1131        .block(Block::default().borders(Borders::ALL).title(title))
1132        .highlight_style(
1133            Style::default()
1134                .bg(Color::DarkGray)
1135                .add_modifier(Modifier::BOLD),
1136        )
1137        .highlight_symbol("> ");
1138
1139    f.render_stateful_widget(list, chunks[0], &mut app.list_state.clone());
1140
1141    let detail = if let Some(entry) = app.selected_repo() {
1142        let sec = entry.security.as_ref();
1143        let icon = |b: bool| -> (&str, Color) {
1144            if b {
1145                ("[Y]", Color::Green)
1146            } else {
1147                ("[N]", Color::Red)
1148            }
1149        };
1150
1151        let mut lines = vec![
1152            Line::from(Span::styled(
1153                entry.repo.name.clone(),
1154                Style::default().fg(Color::Cyan).bold(),
1155            )),
1156            Line::from(""),
1157        ];
1158
1159        // Description (truncated to 2 lines)
1160        if let Some(ref desc) = entry.repo.description
1161            && !desc.is_empty()
1162        {
1163            let max_chars = 80;
1164            if desc.len() > max_chars {
1165                let first = &desc[..max_chars];
1166                let rest = &desc[max_chars..];
1167                lines.push(Line::from(vec![
1168                    Span::raw("  "),
1169                    Span::styled(first, Style::default().fg(Color::DarkGray)),
1170                ]));
1171                let trimmed = if rest.len() > max_chars {
1172                    format!("{}...", &rest[..max_chars.min(rest.len())])
1173                } else {
1174                    rest.to_owned()
1175                };
1176                lines.push(Line::from(vec![
1177                    Span::raw("  "),
1178                    Span::styled(trimmed, Style::default().fg(Color::DarkGray)),
1179                ]));
1180            } else {
1181                lines.push(Line::from(vec![
1182                    Span::raw("  "),
1183                    Span::styled(desc.clone(), Style::default().fg(Color::DarkGray)),
1184                ]));
1185            }
1186            lines.push(Line::from(""));
1187        }
1188
1189        if entry.repo.archived {
1190            lines.push(Line::from(vec![
1191                Span::raw("  "),
1192                Span::styled("[ARCHIVED]", Style::default().fg(Color::Red).bold()),
1193            ]));
1194            lines.push(Line::from(""));
1195        }
1196
1197        lines.extend([
1198            Line::from(vec![
1199                Span::raw("  Language:   "),
1200                Span::styled(
1201                    entry.repo.language.as_deref().unwrap_or("-"),
1202                    Style::default().fg(Color::Yellow),
1203                ),
1204            ]),
1205            Line::from(vec![
1206                Span::raw("  Branch:     "),
1207                Span::raw(&entry.repo.default_branch),
1208            ]),
1209            Line::from(vec![
1210                Span::raw("  Visibility: "),
1211                Span::raw(&entry.repo.visibility),
1212            ]),
1213            Line::from(""),
1214            Line::from(Span::styled(
1215                "  Security",
1216                Style::default().fg(Color::Cyan).bold(),
1217            )),
1218        ]);
1219
1220        if let Some(s) = sec {
1221            let features = [
1222                ("Dependabot Alerts", s.dependabot_alerts),
1223                ("Security Updates", s.dependabot_security_updates),
1224                ("Secret Scanning", s.secret_scanning),
1225                ("AI Detection", s.secret_scanning_ai_detection),
1226                ("Push Protection", s.push_protection),
1227            ];
1228            for (label, enabled) in features {
1229                let (text, color) = icon(enabled);
1230                lines.push(Line::from(vec![
1231                    Span::raw("  "),
1232                    Span::styled(text, Style::default().fg(color)),
1233                    Span::raw(format!(" {label}")),
1234                ]));
1235            }
1236        } else {
1237            lines.push(Line::from(Span::styled(
1238                "  [..] Loading...",
1239                Style::default().fg(Color::DarkGray),
1240            )));
1241        }
1242
1243        lines
1244    } else {
1245        vec![Line::from("  Select a repository")]
1246    };
1247
1248    let detail_widget = Paragraph::new(detail)
1249        .block(Block::default().borders(Borders::ALL).title(" Details "))
1250        .wrap(Wrap { trim: false });
1251
1252    f.render_widget(detail_widget, chunks[1]);
1253}
1254
1255fn draw_security_tab(f: &mut Frame, area: Rect, app: &App) {
1256    if app.repos.is_empty() {
1257        let msg = Paragraph::new("  Press Enter to load repos, then switch to this tab.").block(
1258            Block::default()
1259                .borders(Borders::ALL)
1260                .title(" Security Overview "),
1261        );
1262        f.render_widget(msg, area);
1263        return;
1264    }
1265
1266    let col_repo = 37;
1267    let col_feat = 8;
1268
1269    let header_line = Line::from(vec![
1270        Span::styled(
1271            format!("  {:<col_repo$}", "Repository"),
1272            Style::default().fg(Color::Cyan).bold(),
1273        ),
1274        Span::styled("| ", Style::default().fg(Color::DarkGray)),
1275        Span::styled(
1276            format!("{:<col_feat$}", "Alerts"),
1277            Style::default().fg(Color::Cyan).bold(),
1278        ),
1279        Span::styled("| ", Style::default().fg(Color::DarkGray)),
1280        Span::styled(
1281            format!("{:<col_feat$}", "Secret"),
1282            Style::default().fg(Color::Cyan).bold(),
1283        ),
1284        Span::styled("| ", Style::default().fg(Color::DarkGray)),
1285        Span::styled(
1286            format!("{:<col_feat$}", "AI Det"),
1287            Style::default().fg(Color::Cyan).bold(),
1288        ),
1289        Span::styled("| ", Style::default().fg(Color::DarkGray)),
1290        Span::styled(
1291            format!("{:<col_feat$}", "Push P"),
1292            Style::default().fg(Color::Cyan).bold(),
1293        ),
1294        Span::styled("| ", Style::default().fg(Color::DarkGray)),
1295        Span::styled(
1296            format!("{:<col_feat$}", "Sec Up"),
1297            Style::default().fg(Color::Cyan).bold(),
1298        ),
1299    ]);
1300
1301    let separator = format!(
1302        "  {:-<col_repo$}-+-{:-<col_feat$}-+-{:-<col_feat$}-+-{:-<col_feat$}-+-{:-<col_feat$}-+-{:-<col_feat$}",
1303        "", "", "", "", "", ""
1304    );
1305
1306    let mut lines = vec![
1307        header_line,
1308        Line::from(Span::styled(
1309            separator,
1310            Style::default().fg(Color::DarkGray),
1311        )),
1312    ];
1313
1314    let mut secured = 0;
1315    let mut issues = 0;
1316    let mut pending = 0;
1317
1318    for (row_idx, entry) in app.repos.iter().enumerate() {
1319        let row_bg = if row_idx % 2 == 1 {
1320            Color::Rgb(30, 30, 40)
1321        } else {
1322            Color::Reset
1323        };
1324        let row_style = Style::default().bg(row_bg);
1325
1326        if let Some(s) = entry.security.as_ref() {
1327            let all_ok = s.dependabot_alerts
1328                && s.secret_scanning
1329                && s.secret_scanning_ai_detection
1330                && s.push_protection;
1331
1332            if all_ok {
1333                secured += 1;
1334            } else {
1335                issues += 1;
1336            }
1337
1338            let icon = |b: bool| -> (&str, Color) {
1339                if b {
1340                    ("[Y]", Color::Green)
1341                } else {
1342                    ("[N]", Color::Red)
1343                }
1344            };
1345
1346            let features = [
1347                s.dependabot_alerts,
1348                s.secret_scanning,
1349                s.secret_scanning_ai_detection,
1350                s.push_protection,
1351                s.dependabot_security_updates,
1352            ];
1353
1354            let mut spans = vec![
1355                Span::styled(format!("  {:<col_repo$}", entry.repo.name), row_style),
1356                Span::styled("| ", row_style.fg(Color::DarkGray)),
1357            ];
1358
1359            for (fi, &feat) in features.iter().enumerate() {
1360                let (text, color) = icon(feat);
1361                spans.push(Span::styled(format!(" {text:<6} "), row_style.fg(color)));
1362                if fi < features.len() - 1 {
1363                    spans.push(Span::styled("| ", row_style.fg(Color::DarkGray)));
1364                }
1365            }
1366
1367            lines.push(Line::from(spans));
1368        } else {
1369            pending += 1;
1370            lines.push(Line::from(Span::styled(
1371                format!("  {:<col_repo$}  ...loading", entry.repo.name),
1372                row_style.fg(Color::DarkGray),
1373            )));
1374        }
1375    }
1376
1377    lines.push(Line::from(""));
1378    let summary = if pending > 0 {
1379        format!("  {secured} secured, {issues} issues, {pending} loading...")
1380    } else {
1381        format!("  {secured} secured, {issues} need attention")
1382    };
1383    lines.push(Line::from(Span::styled(
1384        summary,
1385        Style::default()
1386            .fg(if issues > 0 {
1387                Color::Yellow
1388            } else {
1389                Color::Green
1390            })
1391            .bold(),
1392    )));
1393
1394    let widget = Paragraph::new(lines)
1395        .block(
1396            Block::default()
1397                .borders(Borders::ALL)
1398                .title(" Security Overview "),
1399        )
1400        .wrap(Wrap { trim: false });
1401
1402    f.render_widget(widget, area);
1403}
1404
1405fn draw_actions_tab(f: &mut Frame, area: Rect) {
1406    let key_style = Style::default().fg(Color::Yellow).bold();
1407    let heading = Style::default().fg(Color::Cyan).bold();
1408    let dim = Style::default().fg(Color::DarkGray);
1409
1410    let lines = vec![
1411        Line::from(""),
1412        Line::from(Span::styled(
1413            "  Available Actions (on selected repo)",
1414            heading,
1415        )),
1416        Line::from(""),
1417        Line::from(vec![
1418            Span::styled("  a", key_style),
1419            Span::raw("    Apply security settings"),
1420        ]),
1421        Line::from(vec![
1422            Span::styled("  p", key_style),
1423            Span::raw("    Apply branch protection"),
1424        ]),
1425        Line::from(vec![
1426            Span::styled("  t", key_style),
1427            Span::raw("    Deploy template (sub-menu: dependabot/codeql/submission)"),
1428        ]),
1429        Line::from(vec![
1430            Span::styled("  S", key_style),
1431            Span::raw("    Apply settings (copilot ruleset + instructions)"),
1432        ]),
1433        Line::from(""),
1434        Line::from(Span::styled(
1435            "  Bulk Actions (on all filtered repos)",
1436            heading,
1437        )),
1438        Line::from(""),
1439        Line::from(vec![
1440            Span::styled("  A", key_style),
1441            Span::raw("    Apply security to all filtered repos"),
1442        ]),
1443        Line::from(""),
1444        Line::from(Span::styled("  Navigation", heading)),
1445        Line::from(""),
1446        Line::from(vec![
1447            Span::styled("  r", key_style),
1448            Span::raw("    Refresh/reload current system"),
1449        ]),
1450        Line::from(vec![
1451            Span::styled("  R", key_style),
1452            Span::raw("    Force reload (ignore cache)"),
1453        ]),
1454        Line::from(""),
1455        Line::from(Span::styled(
1456            "  Templates deploy a branch + commit + PR for the selected repo.",
1457            dim,
1458        )),
1459        Line::from(Span::styled(
1460            "  Settings creates a copilot review ruleset and instructions file.",
1461            dim,
1462        )),
1463    ];
1464
1465    let widget =
1466        Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(" Actions "));
1467
1468    f.render_widget(widget, area);
1469}
1470
1471fn draw_help_tab(f: &mut Frame, area: Rect) {
1472    let heading = Style::default().fg(Color::Cyan).bold();
1473    let dim = Style::default().fg(Color::DarkGray);
1474    let key = Style::default().fg(Color::Yellow);
1475
1476    let sep = "─".repeat(15);
1477
1478    let help = vec![
1479        Line::from(""),
1480        Line::from(vec![
1481            Span::styled("  Navigation", heading),
1482            Span::raw("                        "),
1483            Span::styled("Repo Actions", heading),
1484        ]),
1485        Line::from(vec![
1486            Span::styled(format!("  {sep}"), dim),
1487            Span::raw("                        "),
1488            Span::styled("─".repeat(12), dim),
1489        ]),
1490        Line::from(vec![
1491            Span::styled("  j/k", key),
1492            Span::raw(" or arrows  Move up/down  "),
1493            Span::styled("a", key),
1494            Span::raw("          Apply security"),
1495        ]),
1496        Line::from(vec![
1497            Span::styled("  Tab/s", key),
1498            Span::raw("          Next system      "),
1499            Span::styled("p", key),
1500            Span::raw("          Apply branch protection"),
1501        ]),
1502        Line::from(vec![
1503            Span::styled("  Shift+Tab", key),
1504            Span::raw("      Previous system  "),
1505            Span::styled("t", key),
1506            Span::raw("          Deploy template (sub-menu)"),
1507        ]),
1508        Line::from(vec![
1509            Span::styled("  Enter/l", key),
1510            Span::raw("        Load repos         "),
1511            Span::styled("S", key),
1512            Span::raw("          Apply settings (copilot)"),
1513        ]),
1514        Line::from(vec![
1515            Span::styled("  /", key),
1516            Span::raw("              Filter (! to exclude)"),
1517        ]),
1518        Line::from(vec![
1519            Span::styled("  Esc", key),
1520            Span::raw("            Clear filter         "),
1521            Span::styled("Bulk Actions", heading),
1522        ]),
1523        Line::from(vec![
1524            Span::raw("                                    "),
1525            Span::styled("─".repeat(12), dim),
1526        ]),
1527        Line::from(vec![
1528            Span::styled("  General", heading),
1529            Span::raw("                           "),
1530            Span::styled("A", key),
1531            Span::raw("   Bulk apply security"),
1532        ]),
1533        Line::from(vec![Span::styled(format!("  {}", "─".repeat(9)), dim)]),
1534        Line::from(vec![
1535            Span::styled("  q", key),
1536            Span::raw("              Quit                "),
1537            Span::styled("Tabs", heading),
1538        ]),
1539        Line::from(vec![
1540            Span::styled("  r", key),
1541            Span::raw("              Reload              "),
1542            Span::styled("─".repeat(4), dim),
1543        ]),
1544        Line::from(vec![
1545            Span::styled("  R", key),
1546            Span::raw("              Force reload        "),
1547            Span::styled("1", key),
1548            Span::raw("   Repos"),
1549        ]),
1550        Line::from(vec![
1551            Span::styled("  ?", key),
1552            Span::raw("              Help                "),
1553            Span::styled("2", key),
1554            Span::raw("   Security"),
1555        ]),
1556        Line::from(vec![
1557            Span::raw("                                    "),
1558            Span::styled("3", key),
1559            Span::raw("   Actions"),
1560        ]),
1561        Line::from(vec![
1562            Span::raw("                                    "),
1563            Span::styled("?", key),
1564            Span::raw("   Help"),
1565        ]),
1566        Line::from(""),
1567        Line::from(Span::styled("  Filter Syntax", heading)),
1568        Line::from(Span::styled(format!("  {}", "─".repeat(13)), dim)),
1569        Line::from(vec![
1570            Span::styled("  foo", key),
1571            Span::raw("             Show repos matching \"foo\""),
1572        ]),
1573        Line::from(vec![
1574            Span::styled("  !ops", key),
1575            Span::raw("            Hide repos matching \"ops\""),
1576        ]),
1577        Line::from(vec![
1578            Span::styled("  !ops !sys", key),
1579            Span::raw("       Hide \"ops\" AND \"sys\" repos"),
1580        ]),
1581        Line::from(vec![
1582            Span::styled("  foo !ops", key),
1583            Span::raw("        Show \"foo\", hide \"ops\""),
1584        ]),
1585        Line::from(""),
1586        Line::from(Span::styled(
1587            "  Stack ! terms like kanban labels to narrow your view.",
1588            dim,
1589        )),
1590        Line::from(Span::styled(
1591            "  E.g. !operation !workflow shows only app repos.",
1592            dim,
1593        )),
1594    ];
1595
1596    let widget = Paragraph::new(help).block(Block::default().borders(Borders::ALL).title(" Help "));
1597
1598    f.render_widget(widget, area);
1599}
1600
1601fn draw_status(f: &mut Frame, area: Rect, app: &App) {
1602    let (sys_id, sys_name) = app
1603        .systems
1604        .get(app.selected_system)
1605        .map(|(id, name)| (id.as_str(), name.as_str()))
1606        .unwrap_or(("?", "none"));
1607
1608    let sys_idx = format!("[{}/{}]", app.selected_system + 1, app.systems.len());
1609    let repo_count = if app.repos.is_empty() {
1610        String::new()
1611    } else {
1612        format!("{} repos", app.repos.len())
1613    };
1614
1615    let status_detail = if app.loading {
1616        format!("[{}] {}", app.spinner_char(), app.status_msg)
1617    } else if app.is_loading_security() {
1618        let loaded = app.repos.iter().filter(|r| r.security.is_some()).count();
1619        let total = app.repos.len();
1620        format!("[{}] Security: {loaded}/{total}...", app.spinner_char())
1621    } else {
1622        app.status_msg.clone()
1623    };
1624
1625    let mut spans = vec![
1626        Span::styled(" System: ", Style::default().fg(Color::DarkGray)),
1627        Span::styled(
1628            format!("{sys_name} ({sys_id})"),
1629            Style::default().fg(Color::Cyan),
1630        ),
1631        Span::styled(format!(" {sys_idx}"), Style::default().fg(Color::DarkGray)),
1632    ];
1633
1634    if !repo_count.is_empty() {
1635        spans.push(Span::styled(
1636            format!(" | {repo_count}"),
1637            Style::default().fg(Color::DarkGray),
1638        ));
1639    }
1640
1641    spans.extend([
1642        Span::raw(" | "),
1643        Span::styled(&status_detail, Style::default().fg(Color::DarkGray)),
1644        Span::raw(" | "),
1645        Span::styled(
1646            "q:quit Tab:sys Enter:load /:filter a/p/t:actions A:bulk",
1647            Style::default().fg(Color::DarkGray),
1648        ),
1649    ]);
1650
1651    let status = Line::from(spans);
1652    let widget = Paragraph::new(status).block(Block::default().borders(Borders::TOP));
1653
1654    f.render_widget(widget, area);
1655}