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 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", ®.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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}