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},
15    text::{Line, Span},
16    widgets::{Block, Borders, Cell, List, ListItem, ListState, Paragraph, Row, Table, 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 sys_label = app
1121        .systems
1122        .get(app.selected_system)
1123        .map(|(_, name)| name.as_str())
1124        .unwrap_or("?");
1125    let title = if app.loading {
1126        format!(" {sys_label} [{}] loading... ", app.spinner_char())
1127    } else if app.is_filtering {
1128        format!(" {sys_label} (filter: {}_) ", app.filter)
1129    } else if app.filter.is_empty() {
1130        format!(" {sys_label} ({}) ", filtered.len())
1131    } else {
1132        format!(" {sys_label} ({}/{}) ", filtered.len(), app.repos.len())
1133    };
1134
1135    let list = List::new(items)
1136        .block(Block::default().borders(Borders::ALL).title(title))
1137        .highlight_style(
1138            Style::default()
1139                .bg(Color::DarkGray)
1140                .add_modifier(Modifier::BOLD),
1141        )
1142        .highlight_symbol("> ");
1143
1144    f.render_stateful_widget(list, chunks[0], &mut app.list_state.clone());
1145
1146    let detail = if let Some(entry) = app.selected_repo() {
1147        let sec = entry.security.as_ref();
1148        let icon = |b: bool| -> (&str, Color) {
1149            if b {
1150                ("[Y]", Color::Green)
1151            } else {
1152                ("[N]", Color::Red)
1153            }
1154        };
1155
1156        let mut lines = vec![
1157            Line::from(Span::styled(
1158                entry.repo.name.clone(),
1159                Style::default().fg(Color::Cyan).bold(),
1160            )),
1161            Line::from(""),
1162        ];
1163
1164        // Description (truncated to 2 lines)
1165        if let Some(ref desc) = entry.repo.description
1166            && !desc.is_empty()
1167        {
1168            let max_chars = 80;
1169            if desc.len() > max_chars {
1170                let first = &desc[..max_chars];
1171                let rest = &desc[max_chars..];
1172                lines.push(Line::from(vec![
1173                    Span::raw("  "),
1174                    Span::styled(first, Style::default().fg(Color::DarkGray)),
1175                ]));
1176                let trimmed = if rest.len() > max_chars {
1177                    format!("{}...", &rest[..max_chars.min(rest.len())])
1178                } else {
1179                    rest.to_owned()
1180                };
1181                lines.push(Line::from(vec![
1182                    Span::raw("  "),
1183                    Span::styled(trimmed, Style::default().fg(Color::DarkGray)),
1184                ]));
1185            } else {
1186                lines.push(Line::from(vec![
1187                    Span::raw("  "),
1188                    Span::styled(desc.clone(), Style::default().fg(Color::DarkGray)),
1189                ]));
1190            }
1191            lines.push(Line::from(""));
1192        }
1193
1194        if entry.repo.archived {
1195            lines.push(Line::from(vec![
1196                Span::raw("  "),
1197                Span::styled("[ARCHIVED]", Style::default().fg(Color::Red).bold()),
1198            ]));
1199            lines.push(Line::from(""));
1200        }
1201
1202        lines.extend([
1203            Line::from(vec![
1204                Span::raw("  Language:   "),
1205                Span::styled(
1206                    entry.repo.language.as_deref().unwrap_or("-"),
1207                    Style::default().fg(Color::Yellow),
1208                ),
1209            ]),
1210            Line::from(vec![
1211                Span::raw("  Branch:     "),
1212                Span::raw(&entry.repo.default_branch),
1213            ]),
1214            Line::from(vec![
1215                Span::raw("  Visibility: "),
1216                Span::raw(&entry.repo.visibility),
1217            ]),
1218            Line::from(""),
1219            Line::from(Span::styled(
1220                "  Security",
1221                Style::default().fg(Color::Cyan).bold(),
1222            )),
1223        ]);
1224
1225        if let Some(s) = sec {
1226            let features = [
1227                ("Dependabot Alerts", s.dependabot_alerts),
1228                ("Security Updates", s.dependabot_security_updates),
1229                ("Secret Scanning", s.secret_scanning),
1230                ("AI Detection", s.secret_scanning_ai_detection),
1231                ("Push Protection", s.push_protection),
1232            ];
1233            for (label, enabled) in features {
1234                let (text, color) = icon(enabled);
1235                lines.push(Line::from(vec![
1236                    Span::raw("  "),
1237                    Span::styled(text, Style::default().fg(color)),
1238                    Span::raw(format!(" {label}")),
1239                ]));
1240            }
1241        } else {
1242            lines.push(Line::from(Span::styled(
1243                "  [..] Loading...",
1244                Style::default().fg(Color::DarkGray),
1245            )));
1246        }
1247
1248        lines
1249    } else {
1250        vec![Line::from("  Select a repository")]
1251    };
1252
1253    let detail_widget = Paragraph::new(detail)
1254        .block(Block::default().borders(Borders::ALL).title(" Details "))
1255        .wrap(Wrap { trim: false });
1256
1257    f.render_widget(detail_widget, chunks[1]);
1258}
1259
1260fn draw_security_tab(f: &mut Frame, area: Rect, app: &App) {
1261    let sys_label = app
1262        .systems
1263        .get(app.selected_system)
1264        .map(|(_, name)| name.as_str())
1265        .unwrap_or("?");
1266    let sys_id = app
1267        .systems
1268        .get(app.selected_system)
1269        .map(|(id, _)| id.as_str())
1270        .unwrap_or("");
1271
1272    if app.repos.is_empty() {
1273        let msg = Paragraph::new("  Press Enter to load repos, then switch to this tab.").block(
1274            Block::default()
1275                .borders(Borders::ALL)
1276                .title(format!(" Security: {sys_label} ")),
1277        );
1278        f.render_widget(msg, area);
1279        return;
1280    }
1281
1282    // Detect common prefix (system-id + dash) to strip from repo names
1283    let prefix = format!("{sys_id}-");
1284    let all_share_prefix = app.repos.iter().all(|e| e.repo.name.starts_with(&prefix));
1285
1286    let display_name = |name: &str| -> String {
1287        if all_share_prefix {
1288            name.strip_prefix(&prefix).unwrap_or(name).to_owned()
1289        } else {
1290            name.to_owned()
1291        }
1292    };
1293
1294    // Dynamic column width from longest display name, capped at 50
1295    let max_name_len = app
1296        .repos
1297        .iter()
1298        .map(|e| display_name(&e.repo.name).len())
1299        .max()
1300        .unwrap_or(20);
1301    let col_repo: usize = max_name_len.clamp(10, 50) + 2;
1302
1303    let truncate_name = |name: &str| -> String {
1304        let dname = display_name(name);
1305        if dname.len() > col_repo {
1306            format!("{}...", &dname[..col_repo - 3])
1307        } else {
1308            dname
1309        }
1310    };
1311
1312    let header_cells = [
1313        "Repository",
1314        "Dependabot",
1315        "Secret Scanning",
1316        "AI Detection",
1317        "Push Protection",
1318        "Security Updates",
1319    ]
1320    .iter()
1321    .map(|h| Cell::from(*h).style(Style::default().fg(Color::Cyan).bold()));
1322    let header = Row::new(header_cells)
1323        .style(Style::default())
1324        .bottom_margin(0);
1325
1326    let mut secured = 0;
1327    let mut issues = 0;
1328    let mut pending = 0;
1329
1330    let rows: Vec<Row> = app
1331        .repos
1332        .iter()
1333        .enumerate()
1334        .map(|(row_idx, entry)| {
1335            let row_bg = if row_idx % 2 == 1 {
1336                Color::Rgb(30, 30, 40)
1337            } else {
1338                Color::Reset
1339            };
1340            let row_style = Style::default().bg(row_bg);
1341
1342            if let Some(s) = entry.security.as_ref() {
1343                let all_ok = s.dependabot_alerts
1344                    && s.secret_scanning
1345                    && s.secret_scanning_ai_detection
1346                    && s.push_protection;
1347                if all_ok {
1348                    secured += 1;
1349                } else {
1350                    issues += 1;
1351                }
1352
1353                let icon = |b: bool| -> Cell {
1354                    if b {
1355                        Cell::from(" [Y]").style(Style::default().fg(Color::Green).bg(row_bg))
1356                    } else {
1357                        Cell::from(" [N]").style(Style::default().fg(Color::Red).bg(row_bg))
1358                    }
1359                };
1360
1361                Row::new(vec![
1362                    Cell::from(truncate_name(&entry.repo.name)).style(Style::default().bg(row_bg)),
1363                    icon(s.dependabot_alerts),
1364                    icon(s.secret_scanning),
1365                    icon(s.secret_scanning_ai_detection),
1366                    icon(s.push_protection),
1367                    icon(s.dependabot_security_updates),
1368                ])
1369                .style(row_style)
1370            } else {
1371                pending += 1;
1372                Row::new(vec![
1373                    Cell::from(truncate_name(&entry.repo.name))
1374                        .style(Style::default().fg(Color::DarkGray).bg(row_bg)),
1375                    Cell::from(" [..]").style(Style::default().fg(Color::DarkGray).bg(row_bg)),
1376                    Cell::from(" [..]").style(Style::default().fg(Color::DarkGray).bg(row_bg)),
1377                    Cell::from(" [..]").style(Style::default().fg(Color::DarkGray).bg(row_bg)),
1378                    Cell::from(" [..]").style(Style::default().fg(Color::DarkGray).bg(row_bg)),
1379                    Cell::from(" [..]").style(Style::default().fg(Color::DarkGray).bg(row_bg)),
1380                ])
1381                .style(row_style)
1382            }
1383        })
1384        .collect();
1385
1386    let widths = [
1387        Constraint::Min(col_repo as u16),
1388        Constraint::Min(12),
1389        Constraint::Min(17),
1390        Constraint::Min(14),
1391        Constraint::Min(17),
1392        Constraint::Min(18),
1393    ];
1394
1395    let prefix_note = if all_share_prefix {
1396        format!(" (prefix {prefix} stripped)")
1397    } else {
1398        String::new()
1399    };
1400
1401    let table = Table::new(rows, widths)
1402        .header(header)
1403        .block(
1404            Block::default()
1405                .borders(Borders::ALL)
1406                .title(format!(" Security: {sys_label}{prefix_note} ")),
1407        )
1408        .column_spacing(1);
1409
1410    // Split area: table gets main space, summary gets 1 line at bottom
1411    let layout = Layout::default()
1412        .direction(Direction::Vertical)
1413        .constraints([Constraint::Min(5), Constraint::Length(1)])
1414        .split(area);
1415
1416    f.render_widget(table, layout[0]);
1417
1418    let summary = if pending > 0 {
1419        format!("  {secured} secured, {issues} issues, {pending} loading...")
1420    } else {
1421        format!("  {secured} secured, {issues} need attention")
1422    };
1423    let summary_widget = Paragraph::new(Line::from(Span::styled(
1424        summary,
1425        Style::default()
1426            .fg(if issues > 0 {
1427                Color::Yellow
1428            } else {
1429                Color::Green
1430            })
1431            .bold(),
1432    )));
1433    f.render_widget(summary_widget, layout[1]);
1434}
1435
1436fn draw_actions_tab(f: &mut Frame, area: Rect) {
1437    let key_style = Style::default().fg(Color::Yellow).bold();
1438    let heading = Style::default().fg(Color::Cyan).bold();
1439    let dim = Style::default().fg(Color::DarkGray);
1440
1441    let lines = vec![
1442        Line::from(""),
1443        Line::from(Span::styled(
1444            "  Available Actions (on selected repo)",
1445            heading,
1446        )),
1447        Line::from(""),
1448        Line::from(vec![
1449            Span::styled("  a", key_style),
1450            Span::raw("    Apply security settings"),
1451        ]),
1452        Line::from(vec![
1453            Span::styled("  p", key_style),
1454            Span::raw("    Apply branch protection"),
1455        ]),
1456        Line::from(vec![
1457            Span::styled("  t", key_style),
1458            Span::raw("    Deploy template (sub-menu: dependabot/codeql/submission)"),
1459        ]),
1460        Line::from(vec![
1461            Span::styled("  S", key_style),
1462            Span::raw("    Apply settings (copilot ruleset + instructions)"),
1463        ]),
1464        Line::from(""),
1465        Line::from(Span::styled(
1466            "  Bulk Actions (on all filtered repos)",
1467            heading,
1468        )),
1469        Line::from(""),
1470        Line::from(vec![
1471            Span::styled("  A", key_style),
1472            Span::raw("    Apply security to all filtered repos"),
1473        ]),
1474        Line::from(""),
1475        Line::from(Span::styled("  Navigation", heading)),
1476        Line::from(""),
1477        Line::from(vec![
1478            Span::styled("  r", key_style),
1479            Span::raw("    Refresh/reload current system"),
1480        ]),
1481        Line::from(vec![
1482            Span::styled("  R", key_style),
1483            Span::raw("    Force reload (ignore cache)"),
1484        ]),
1485        Line::from(""),
1486        Line::from(Span::styled(
1487            "  Templates deploy a branch + commit + PR for the selected repo.",
1488            dim,
1489        )),
1490        Line::from(Span::styled(
1491            "  Settings creates a copilot review ruleset and instructions file.",
1492            dim,
1493        )),
1494    ];
1495
1496    let widget =
1497        Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(" Actions "));
1498
1499    f.render_widget(widget, area);
1500}
1501
1502fn draw_help_tab(f: &mut Frame, area: Rect) {
1503    let heading = Style::default().fg(Color::Cyan).bold();
1504    let dim = Style::default().fg(Color::DarkGray);
1505    let key = Style::default().fg(Color::Yellow);
1506
1507    let block = Block::default().borders(Borders::ALL).title(" Help ");
1508    let inner = block.inner(area);
1509    f.render_widget(block, area);
1510
1511    let columns = Layout::default()
1512        .direction(Direction::Horizontal)
1513        .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
1514        .split(inner);
1515
1516    // Helper: build a section with a heading, separator, and key/desc rows
1517    fn section<'a>(
1518        title: &'a str,
1519        entries: &[(&'a str, &'a str)],
1520        heading: Style,
1521        dim: Style,
1522        key_style: Style,
1523    ) -> Vec<Line<'a>> {
1524        let mut lines = vec![
1525            Line::from(Span::styled(format!(" {title}"), heading)),
1526            Line::from(Span::styled(
1527                format!(" {}", "\u{2500}".repeat(title.len())),
1528                dim,
1529            )),
1530        ];
1531        for &(k, desc) in entries {
1532            lines.push(Line::from(vec![
1533                Span::styled(format!(" {k:<15}"), key_style),
1534                Span::raw(desc),
1535            ]));
1536        }
1537        lines.push(Line::from(""));
1538        lines
1539    }
1540
1541    // Left column
1542    let mut left: Vec<Line> = Vec::new();
1543    left.push(Line::from(""));
1544    left.extend(section(
1545        "Navigation",
1546        &[
1547            ("j/k, arrows", "Move up/down"),
1548            ("Tab/s", "Next system"),
1549            ("Shift+Tab", "Previous system"),
1550            ("Enter/l", "Load repos"),
1551            ("/", "Filter"),
1552            ("Esc", "Clear filter"),
1553        ],
1554        heading,
1555        dim,
1556        key,
1557    ));
1558    left.extend(section(
1559        "General",
1560        &[
1561            ("q", "Quit"),
1562            ("r", "Reload"),
1563            ("R", "Force reload"),
1564            ("?", "Help"),
1565        ],
1566        heading,
1567        dim,
1568        key,
1569    ));
1570    left.extend(section(
1571        "Filter Syntax",
1572        &[
1573            ("foo", "Show matching"),
1574            ("!ops", "Hide matching"),
1575            ("!ops !sys", "Hide both"),
1576            ("foo !ops", "Show foo, hide ops"),
1577        ],
1578        heading,
1579        dim,
1580        key,
1581    ));
1582    left.push(Line::from(Span::styled(
1583        " Stack ! terms like kanban labels to narrow view.",
1584        dim,
1585    )));
1586
1587    // Right column
1588    let mut right: Vec<Line> = Vec::new();
1589    right.push(Line::from(""));
1590    right.extend(section(
1591        "Repo Actions",
1592        &[
1593            ("a", "Apply security"),
1594            ("p", "Apply branch protection"),
1595            ("t", "Deploy template (d/c/s)"),
1596            ("S", "Apply settings (copilot)"),
1597        ],
1598        heading,
1599        dim,
1600        key,
1601    ));
1602    right.extend(section(
1603        "Bulk Actions",
1604        &[("A", "Apply security to all")],
1605        heading,
1606        dim,
1607        key,
1608    ));
1609    right.extend(section(
1610        "Tabs",
1611        &[
1612            ("1", "Repos"),
1613            ("2", "Security"),
1614            ("3", "Actions"),
1615            ("?", "Help"),
1616        ],
1617        heading,
1618        dim,
1619        key,
1620    ));
1621
1622    let left_widget = Paragraph::new(left);
1623    let right_widget = Paragraph::new(right);
1624
1625    f.render_widget(left_widget, columns[0]);
1626    f.render_widget(right_widget, columns[1]);
1627}
1628
1629fn draw_status(f: &mut Frame, area: Rect, app: &App) {
1630    let (sys_id, sys_name) = app
1631        .systems
1632        .get(app.selected_system)
1633        .map(|(id, name)| (id.as_str(), name.as_str()))
1634        .unwrap_or(("?", "none"));
1635
1636    let sys_idx = format!("[{}/{}]", app.selected_system + 1, app.systems.len());
1637    let repo_count = if app.repos.is_empty() {
1638        String::new()
1639    } else {
1640        format!("{} repos", app.repos.len())
1641    };
1642
1643    let status_detail = if app.loading {
1644        format!("[{}] {}", app.spinner_char(), app.status_msg)
1645    } else if app.is_loading_security() {
1646        let loaded = app.repos.iter().filter(|r| r.security.is_some()).count();
1647        let total = app.repos.len();
1648        format!("[{}] Security: {loaded}/{total}...", app.spinner_char())
1649    } else {
1650        app.status_msg.clone()
1651    };
1652
1653    let mut spans = vec![
1654        Span::styled(" System: ", Style::default().fg(Color::DarkGray)),
1655        Span::styled(
1656            format!("{sys_name} ({sys_id})"),
1657            Style::default().fg(Color::Cyan),
1658        ),
1659        Span::styled(format!(" {sys_idx}"), Style::default().fg(Color::DarkGray)),
1660    ];
1661
1662    if !repo_count.is_empty() {
1663        spans.push(Span::styled(
1664            format!(" | {repo_count}"),
1665            Style::default().fg(Color::DarkGray),
1666        ));
1667    }
1668
1669    spans.extend([
1670        Span::raw(" | "),
1671        Span::styled(&status_detail, Style::default().fg(Color::DarkGray)),
1672        Span::raw(" | "),
1673        Span::styled(
1674            "q:quit Tab:sys Enter:load /:filter a/p/t:actions A:bulk",
1675            Style::default().fg(Color::DarkGray),
1676        ),
1677    ]);
1678
1679    let status = Line::from(spans);
1680    let widget = Paragraph::new(status).block(Block::default().borders(Borders::TOP));
1681
1682    f.render_widget(widget, area);
1683}