1use std::io;
2use std::time::Duration;
3
4use anyhow::Result;
5use crossterm::{
6 ExecutableCommand,
7 event::{self, Event, KeyCode, KeyEventKind},
8 terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
9};
10use ratatui::{
11 Frame, Terminal,
12 layout::{Constraint, Direction, Layout, Rect},
13 prelude::CrosstermBackend,
14 style::{Color, Modifier, Style, Stylize},
15 text::{Line, Span},
16 widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs, Wrap},
17};
18use tokio::sync::mpsc;
19
20use crate::config::manifest::BranchProtectionConfig;
21use crate::config::templates::load_templates_with_custom_dir;
22use crate::config::{Manifest, SecurityConfig};
23use crate::detection::project_type::ProjectType;
24use crate::github::Client;
25use crate::github::commits::CommitFile;
26use crate::github::repos::Repository;
27use crate::github::security::SecurityState;
28
29const SPINNER_FRAMES: [char; 4] = ['|', '/', '-', '\\'];
30
31enum Tab {
32 Repos,
33 Security,
34 Actions,
35 Help,
36}
37
38enum BgMessage {
39 ReposLoaded(Vec<RepoEntry>),
40 SecurityLoaded(usize, SecurityState),
41 SecurityApplied(String, std::result::Result<(), String>),
42 ProtectionApplied(String, std::result::Result<(), String>),
43 TemplateDeployed(String, String, std::result::Result<String, String>),
44 SettingsApplied(String, std::result::Result<String, String>),
45 Error(String),
46}
47
48enum PendingAction {
49 ApplySecurity(String),
50 ApplyProtection(String),
51 SelectTemplate(String),
52 DeployTemplate(String, String),
53 ApplySettings(String),
54 BulkApplySecurity,
55}
56
57struct App {
58 tab: Tab,
59 repos: Vec<RepoEntry>,
60 list_state: ListState,
61 filter: String,
62 is_filtering: bool,
63 should_quit: bool,
64 status_msg: String,
65 systems: Vec<(String, String)>,
66 selected_system: usize,
67 loading: bool,
68 spinner_frame: usize,
69 pending_confirm: Option<PendingAction>,
70 cache: std::collections::HashMap<String, Vec<RepoEntry>>,
71 bulk_progress: Option<(usize, usize)>,
72}
73
74#[derive(Clone)]
75struct RepoEntry {
76 repo: Repository,
77 security: Option<SecurityState>,
78}
79
80impl App {
81 fn new(systems: Vec<(String, String)>) -> Self {
82 let mut state = ListState::default();
83 state.select(Some(0));
84 Self {
85 tab: Tab::Repos,
86 repos: Vec::new(),
87 list_state: state,
88 filter: String::new(),
89 is_filtering: false,
90 should_quit: false,
91 status_msg: "Press Enter to load repos".to_owned(),
92 systems,
93 selected_system: 0,
94 loading: false,
95 spinner_frame: 0,
96 pending_confirm: None,
97 cache: std::collections::HashMap::new(),
98 bulk_progress: None,
99 }
100 }
101
102 fn filtered_repos(&self) -> Vec<&RepoEntry> {
103 if self.filter.is_empty() {
104 return self.repos.iter().collect();
105 }
106
107 let terms: Vec<&str> = self.filter.split_whitespace().collect();
108 let includes: Vec<&str> = terms
109 .iter()
110 .filter(|t| !t.starts_with('!'))
111 .copied()
112 .collect();
113 let excludes: Vec<String> = terms
114 .iter()
115 .filter(|t| t.starts_with('!'))
116 .map(|t| t[1..].to_lowercase())
117 .collect();
118
119 self.repos
120 .iter()
121 .filter(|r| {
122 let name = r.repo.name.to_lowercase();
123 let include_ok = includes.is_empty()
124 || includes
125 .iter()
126 .any(|inc| name.contains(&inc.to_lowercase()));
127 let exclude_ok = excludes.iter().all(|exc| !name.contains(exc.as_str()));
128 include_ok && exclude_ok
129 })
130 .collect()
131 }
132
133 fn selected_repo(&self) -> Option<&RepoEntry> {
134 let filtered = self.filtered_repos();
135 self.list_state
136 .selected()
137 .and_then(|i| filtered.get(i).copied())
138 }
139
140 fn spinner_char(&self) -> char {
141 SPINNER_FRAMES[self.spinner_frame % SPINNER_FRAMES.len()]
142 }
143
144 fn is_loading_security(&self) -> bool {
145 !self.repos.is_empty() && self.repos.iter().any(|r| r.security.is_none())
146 }
147}
148
149pub async fn run(client: &Client, manifest: &Manifest) -> Result<()> {
150 let systems: Vec<(String, String)> = manifest
151 .systems
152 .iter()
153 .map(|s| (s.id.clone(), s.name.clone()))
154 .collect();
155
156 if systems.is_empty() {
157 anyhow::bail!("No systems defined in ward.toml - add [[systems]] entries first");
158 }
159
160 enable_raw_mode()?;
161 io::stdout().execute(EnterAlternateScreen)?;
162
163 let backend = CrosstermBackend::new(io::stdout());
164 let mut terminal = Terminal::new(backend)?;
165 let mut app = App::new(systems);
166
167 let result = run_loop(&mut terminal, &mut app, client, manifest).await;
168
169 disable_raw_mode()?;
170 io::stdout().execute(LeaveAlternateScreen)?;
171
172 result
173}
174
175fn spawn_repo_load(
176 tx: &mpsc::UnboundedSender<BgMessage>,
177 client: &Client,
178 manifest: &Manifest,
179 system_id: &str,
180) {
181 let tx = tx.clone();
182 let org = client.org.clone();
183 let http = client.http.clone();
184 let base_url = client.base_url.clone();
185 let semaphore = client.semaphore.clone();
186 let excludes = manifest.exclude_patterns_for_system(system_id);
187 let explicit = manifest.explicit_repos_for_system(system_id);
188 let sys_id = system_id.to_owned();
189
190 tokio::spawn(async move {
191 let bg_client = Client {
192 http,
193 org,
194 semaphore,
195 base_url,
196 };
197
198 match bg_client
199 .list_repos_for_system(&sys_id, &excludes, &explicit)
200 .await
201 {
202 Ok(repos) => {
203 let entries: Vec<RepoEntry> = repos
204 .into_iter()
205 .map(|repo| RepoEntry {
206 repo,
207 security: None,
208 })
209 .collect();
210 let _ = tx.send(BgMessage::ReposLoaded(entries));
211 }
212 Err(e) => {
213 let _ = tx.send(BgMessage::Error(e.to_string()));
214 }
215 }
216 });
217}
218
219fn spawn_security_load(
220 tx: &mpsc::UnboundedSender<BgMessage>,
221 client: &Client,
222 repos: &[RepoEntry],
223) {
224 for (idx, entry) in repos.iter().enumerate() {
225 let tx = tx.clone();
226 let http = client.http.clone();
227 let org = client.org.clone();
228 let base_url = client.base_url.clone();
229 let semaphore = client.semaphore.clone();
230 let repo_name = entry.repo.name.clone();
231
232 tokio::spawn(async move {
233 let bg_client = Client {
234 http,
235 org,
236 semaphore,
237 base_url,
238 };
239
240 if let Ok(state) = bg_client.get_security_state(&repo_name).await {
241 let _ = tx.send(BgMessage::SecurityLoaded(idx, state));
242 }
243 });
244 }
245}
246
247fn spawn_security_apply(
248 tx: &mpsc::UnboundedSender<BgMessage>,
249 client: &Client,
250 repo_name: &str,
251 config: &SecurityConfig,
252) {
253 let tx = tx.clone();
254 let http = client.http.clone();
255 let org = client.org.clone();
256 let base_url = client.base_url.clone();
257 let semaphore = client.semaphore.clone();
258 let repo = repo_name.to_owned();
259 let cfg = config.clone();
260
261 tokio::spawn(async move {
262 let bg_client = Client {
263 http,
264 org,
265 semaphore,
266 base_url,
267 };
268
269 let result = async {
270 if cfg.dependabot_alerts {
271 bg_client.enable_dependabot_alerts(&repo).await?;
272 }
273 if cfg.dependabot_security_updates {
274 bg_client.enable_dependabot_security_updates(&repo).await?;
275 }
276 bg_client
277 .set_security_features(
278 &repo,
279 cfg.secret_scanning,
280 cfg.secret_scanning_ai_detection,
281 cfg.push_protection,
282 )
283 .await?;
284 Ok::<(), anyhow::Error>(())
285 }
286 .await;
287
288 let msg = match result {
289 Ok(()) => BgMessage::SecurityApplied(repo, Ok(())),
290 Err(e) => BgMessage::SecurityApplied(repo, Err(e.to_string())),
291 };
292 let _ = tx.send(msg);
293 });
294}
295
296fn spawn_protection_apply(
297 tx: &mpsc::UnboundedSender<BgMessage>,
298 client: &Client,
299 repo: &str,
300 branch: &str,
301 config: &BranchProtectionConfig,
302) {
303 let tx = tx.clone();
304 let http = client.http.clone();
305 let org = client.org.clone();
306 let base_url = client.base_url.clone();
307 let semaphore = client.semaphore.clone();
308 let repo = repo.to_owned();
309 let branch = branch.to_owned();
310 let cfg = config.clone();
311
312 tokio::spawn(async move {
313 let bg_client = Client {
314 http,
315 org,
316 semaphore,
317 base_url,
318 };
319
320 let result = bg_client
321 .update_branch_protection(&repo, &branch, &cfg)
322 .await;
323
324 let msg = match result {
325 Ok(()) => BgMessage::ProtectionApplied(repo, Ok(())),
326 Err(e) => BgMessage::ProtectionApplied(repo, Err(e.to_string())),
327 };
328 let _ = tx.send(msg);
329 });
330}
331
332fn spawn_template_deploy(
333 tx: &mpsc::UnboundedSender<BgMessage>,
334 client: &Client,
335 manifest: &Manifest,
336 repo_name: &str,
337 default_branch: &str,
338 template_name: &str,
339) {
340 let tx = tx.clone();
341 let http = client.http.clone();
342 let org = client.org.clone();
343 let base_url = client.base_url.clone();
344 let semaphore = client.semaphore.clone();
345 let repo = repo_name.to_owned();
346 let default_br = default_branch.to_owned();
347 let template = template_name.to_owned();
348 let branch_name = manifest.templates.branch.clone();
349 let reviewers = manifest.templates.reviewers.clone();
350 let commit_prefix = manifest.templates.commit_message_prefix.clone();
351 let custom_dir = manifest.templates.custom_dir.clone();
352 let registries = manifest.templates.registries.clone();
353
354 tokio::spawn(async move {
355 let bg_client = Client {
356 http,
357 org,
358 semaphore,
359 base_url,
360 };
361
362 let result = async {
363 let (target_path, template_category) = match template.as_str() {
364 "dependabot" => (".github/dependabot.yml", "dependabot"),
365 "codeql" => (".github/workflows/codeql.yml", "codeql"),
366 "dependency-submission" => (
367 ".github/workflows/dependency-submission.yml",
368 "dependency-submission",
369 ),
370 _ => return Err(format!("Unknown template: {template}")),
371 };
372
373 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 title = if app.loading {
1121 format!(" Repos [{}] loading... ", app.spinner_char())
1122 } else if app.is_filtering {
1123 format!(" Repos (filter: {}_) ", app.filter)
1124 } else if app.filter.is_empty() {
1125 format!(" Repos ({}) ", filtered.len())
1126 } else {
1127 format!(" Repos ({}/{}) ", filtered.len(), app.repos.len())
1128 };
1129
1130 let list = List::new(items)
1131 .block(Block::default().borders(Borders::ALL).title(title))
1132 .highlight_style(
1133 Style::default()
1134 .bg(Color::DarkGray)
1135 .add_modifier(Modifier::BOLD),
1136 )
1137 .highlight_symbol("> ");
1138
1139 f.render_stateful_widget(list, chunks[0], &mut app.list_state.clone());
1140
1141 let detail = if let Some(entry) = app.selected_repo() {
1142 let sec = entry.security.as_ref();
1143 let icon = |b: bool| -> (&str, Color) {
1144 if b {
1145 ("[Y]", Color::Green)
1146 } else {
1147 ("[N]", Color::Red)
1148 }
1149 };
1150
1151 let mut lines = vec![
1152 Line::from(Span::styled(
1153 entry.repo.name.clone(),
1154 Style::default().fg(Color::Cyan).bold(),
1155 )),
1156 Line::from(""),
1157 ];
1158
1159 if let Some(ref desc) = entry.repo.description
1161 && !desc.is_empty()
1162 {
1163 let max_chars = 80;
1164 if desc.len() > max_chars {
1165 let first = &desc[..max_chars];
1166 let rest = &desc[max_chars..];
1167 lines.push(Line::from(vec![
1168 Span::raw(" "),
1169 Span::styled(first, Style::default().fg(Color::DarkGray)),
1170 ]));
1171 let trimmed = if rest.len() > max_chars {
1172 format!("{}...", &rest[..max_chars.min(rest.len())])
1173 } else {
1174 rest.to_owned()
1175 };
1176 lines.push(Line::from(vec![
1177 Span::raw(" "),
1178 Span::styled(trimmed, Style::default().fg(Color::DarkGray)),
1179 ]));
1180 } else {
1181 lines.push(Line::from(vec![
1182 Span::raw(" "),
1183 Span::styled(desc.clone(), Style::default().fg(Color::DarkGray)),
1184 ]));
1185 }
1186 lines.push(Line::from(""));
1187 }
1188
1189 if entry.repo.archived {
1190 lines.push(Line::from(vec![
1191 Span::raw(" "),
1192 Span::styled("[ARCHIVED]", Style::default().fg(Color::Red).bold()),
1193 ]));
1194 lines.push(Line::from(""));
1195 }
1196
1197 lines.extend([
1198 Line::from(vec![
1199 Span::raw(" Language: "),
1200 Span::styled(
1201 entry.repo.language.as_deref().unwrap_or("-"),
1202 Style::default().fg(Color::Yellow),
1203 ),
1204 ]),
1205 Line::from(vec![
1206 Span::raw(" Branch: "),
1207 Span::raw(&entry.repo.default_branch),
1208 ]),
1209 Line::from(vec![
1210 Span::raw(" Visibility: "),
1211 Span::raw(&entry.repo.visibility),
1212 ]),
1213 Line::from(""),
1214 Line::from(Span::styled(
1215 " Security",
1216 Style::default().fg(Color::Cyan).bold(),
1217 )),
1218 ]);
1219
1220 if let Some(s) = sec {
1221 let features = [
1222 ("Dependabot Alerts", s.dependabot_alerts),
1223 ("Security Updates", s.dependabot_security_updates),
1224 ("Secret Scanning", s.secret_scanning),
1225 ("AI Detection", s.secret_scanning_ai_detection),
1226 ("Push Protection", s.push_protection),
1227 ];
1228 for (label, enabled) in features {
1229 let (text, color) = icon(enabled);
1230 lines.push(Line::from(vec![
1231 Span::raw(" "),
1232 Span::styled(text, Style::default().fg(color)),
1233 Span::raw(format!(" {label}")),
1234 ]));
1235 }
1236 } else {
1237 lines.push(Line::from(Span::styled(
1238 " [..] Loading...",
1239 Style::default().fg(Color::DarkGray),
1240 )));
1241 }
1242
1243 lines
1244 } else {
1245 vec![Line::from(" Select a repository")]
1246 };
1247
1248 let detail_widget = Paragraph::new(detail)
1249 .block(Block::default().borders(Borders::ALL).title(" Details "))
1250 .wrap(Wrap { trim: false });
1251
1252 f.render_widget(detail_widget, chunks[1]);
1253}
1254
1255fn draw_security_tab(f: &mut Frame, area: Rect, app: &App) {
1256 if app.repos.is_empty() {
1257 let msg = Paragraph::new(" Press Enter to load repos, then switch to this tab.").block(
1258 Block::default()
1259 .borders(Borders::ALL)
1260 .title(" Security Overview "),
1261 );
1262 f.render_widget(msg, area);
1263 return;
1264 }
1265
1266 let col_repo = 37;
1267 let col_feat = 8;
1268
1269 let header_line = Line::from(vec![
1270 Span::styled(
1271 format!(" {:<col_repo$}", "Repository"),
1272 Style::default().fg(Color::Cyan).bold(),
1273 ),
1274 Span::styled("| ", Style::default().fg(Color::DarkGray)),
1275 Span::styled(
1276 format!("{:<col_feat$}", "Alerts"),
1277 Style::default().fg(Color::Cyan).bold(),
1278 ),
1279 Span::styled("| ", Style::default().fg(Color::DarkGray)),
1280 Span::styled(
1281 format!("{:<col_feat$}", "Secret"),
1282 Style::default().fg(Color::Cyan).bold(),
1283 ),
1284 Span::styled("| ", Style::default().fg(Color::DarkGray)),
1285 Span::styled(
1286 format!("{:<col_feat$}", "AI Det"),
1287 Style::default().fg(Color::Cyan).bold(),
1288 ),
1289 Span::styled("| ", Style::default().fg(Color::DarkGray)),
1290 Span::styled(
1291 format!("{:<col_feat$}", "Push P"),
1292 Style::default().fg(Color::Cyan).bold(),
1293 ),
1294 Span::styled("| ", Style::default().fg(Color::DarkGray)),
1295 Span::styled(
1296 format!("{:<col_feat$}", "Sec Up"),
1297 Style::default().fg(Color::Cyan).bold(),
1298 ),
1299 ]);
1300
1301 let separator = format!(
1302 " {:-<col_repo$}-+-{:-<col_feat$}-+-{:-<col_feat$}-+-{:-<col_feat$}-+-{:-<col_feat$}-+-{:-<col_feat$}",
1303 "", "", "", "", "", ""
1304 );
1305
1306 let mut lines = vec![
1307 header_line,
1308 Line::from(Span::styled(
1309 separator,
1310 Style::default().fg(Color::DarkGray),
1311 )),
1312 ];
1313
1314 let mut secured = 0;
1315 let mut issues = 0;
1316 let mut pending = 0;
1317
1318 for (row_idx, entry) in app.repos.iter().enumerate() {
1319 let row_bg = if row_idx % 2 == 1 {
1320 Color::Rgb(30, 30, 40)
1321 } else {
1322 Color::Reset
1323 };
1324 let row_style = Style::default().bg(row_bg);
1325
1326 if let Some(s) = entry.security.as_ref() {
1327 let all_ok = s.dependabot_alerts
1328 && s.secret_scanning
1329 && s.secret_scanning_ai_detection
1330 && s.push_protection;
1331
1332 if all_ok {
1333 secured += 1;
1334 } else {
1335 issues += 1;
1336 }
1337
1338 let icon = |b: bool| -> (&str, Color) {
1339 if b {
1340 ("[Y]", Color::Green)
1341 } else {
1342 ("[N]", Color::Red)
1343 }
1344 };
1345
1346 let features = [
1347 s.dependabot_alerts,
1348 s.secret_scanning,
1349 s.secret_scanning_ai_detection,
1350 s.push_protection,
1351 s.dependabot_security_updates,
1352 ];
1353
1354 let mut spans = vec![
1355 Span::styled(format!(" {:<col_repo$}", entry.repo.name), row_style),
1356 Span::styled("| ", row_style.fg(Color::DarkGray)),
1357 ];
1358
1359 for (fi, &feat) in features.iter().enumerate() {
1360 let (text, color) = icon(feat);
1361 spans.push(Span::styled(format!(" {text:<6} "), row_style.fg(color)));
1362 if fi < features.len() - 1 {
1363 spans.push(Span::styled("| ", row_style.fg(Color::DarkGray)));
1364 }
1365 }
1366
1367 lines.push(Line::from(spans));
1368 } else {
1369 pending += 1;
1370 lines.push(Line::from(Span::styled(
1371 format!(" {:<col_repo$} ...loading", entry.repo.name),
1372 row_style.fg(Color::DarkGray),
1373 )));
1374 }
1375 }
1376
1377 lines.push(Line::from(""));
1378 let summary = if pending > 0 {
1379 format!(" {secured} secured, {issues} issues, {pending} loading...")
1380 } else {
1381 format!(" {secured} secured, {issues} need attention")
1382 };
1383 lines.push(Line::from(Span::styled(
1384 summary,
1385 Style::default()
1386 .fg(if issues > 0 {
1387 Color::Yellow
1388 } else {
1389 Color::Green
1390 })
1391 .bold(),
1392 )));
1393
1394 let widget = Paragraph::new(lines)
1395 .block(
1396 Block::default()
1397 .borders(Borders::ALL)
1398 .title(" Security Overview "),
1399 )
1400 .wrap(Wrap { trim: false });
1401
1402 f.render_widget(widget, area);
1403}
1404
1405fn draw_actions_tab(f: &mut Frame, area: Rect) {
1406 let key_style = Style::default().fg(Color::Yellow).bold();
1407 let heading = Style::default().fg(Color::Cyan).bold();
1408 let dim = Style::default().fg(Color::DarkGray);
1409
1410 let lines = vec![
1411 Line::from(""),
1412 Line::from(Span::styled(
1413 " Available Actions (on selected repo)",
1414 heading,
1415 )),
1416 Line::from(""),
1417 Line::from(vec![
1418 Span::styled(" a", key_style),
1419 Span::raw(" Apply security settings"),
1420 ]),
1421 Line::from(vec![
1422 Span::styled(" p", key_style),
1423 Span::raw(" Apply branch protection"),
1424 ]),
1425 Line::from(vec![
1426 Span::styled(" t", key_style),
1427 Span::raw(" Deploy template (sub-menu: dependabot/codeql/submission)"),
1428 ]),
1429 Line::from(vec![
1430 Span::styled(" S", key_style),
1431 Span::raw(" Apply settings (copilot ruleset + instructions)"),
1432 ]),
1433 Line::from(""),
1434 Line::from(Span::styled(
1435 " Bulk Actions (on all filtered repos)",
1436 heading,
1437 )),
1438 Line::from(""),
1439 Line::from(vec![
1440 Span::styled(" A", key_style),
1441 Span::raw(" Apply security to all filtered repos"),
1442 ]),
1443 Line::from(""),
1444 Line::from(Span::styled(" Navigation", heading)),
1445 Line::from(""),
1446 Line::from(vec![
1447 Span::styled(" r", key_style),
1448 Span::raw(" Refresh/reload current system"),
1449 ]),
1450 Line::from(vec![
1451 Span::styled(" R", key_style),
1452 Span::raw(" Force reload (ignore cache)"),
1453 ]),
1454 Line::from(""),
1455 Line::from(Span::styled(
1456 " Templates deploy a branch + commit + PR for the selected repo.",
1457 dim,
1458 )),
1459 Line::from(Span::styled(
1460 " Settings creates a copilot review ruleset and instructions file.",
1461 dim,
1462 )),
1463 ];
1464
1465 let widget =
1466 Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(" Actions "));
1467
1468 f.render_widget(widget, area);
1469}
1470
1471fn draw_help_tab(f: &mut Frame, area: Rect) {
1472 let heading = Style::default().fg(Color::Cyan).bold();
1473 let dim = Style::default().fg(Color::DarkGray);
1474 let key = Style::default().fg(Color::Yellow);
1475
1476 let sep = "─".repeat(15);
1477
1478 let help = vec![
1479 Line::from(""),
1480 Line::from(vec![
1481 Span::styled(" Navigation", heading),
1482 Span::raw(" "),
1483 Span::styled("Repo Actions", heading),
1484 ]),
1485 Line::from(vec![
1486 Span::styled(format!(" {sep}"), dim),
1487 Span::raw(" "),
1488 Span::styled("─".repeat(12), dim),
1489 ]),
1490 Line::from(vec![
1491 Span::styled(" j/k", key),
1492 Span::raw(" or arrows Move up/down "),
1493 Span::styled("a", key),
1494 Span::raw(" Apply security"),
1495 ]),
1496 Line::from(vec![
1497 Span::styled(" Tab/s", key),
1498 Span::raw(" Next system "),
1499 Span::styled("p", key),
1500 Span::raw(" Apply branch protection"),
1501 ]),
1502 Line::from(vec![
1503 Span::styled(" Shift+Tab", key),
1504 Span::raw(" Previous system "),
1505 Span::styled("t", key),
1506 Span::raw(" Deploy template (sub-menu)"),
1507 ]),
1508 Line::from(vec![
1509 Span::styled(" Enter/l", key),
1510 Span::raw(" Load repos "),
1511 Span::styled("S", key),
1512 Span::raw(" Apply settings (copilot)"),
1513 ]),
1514 Line::from(vec![
1515 Span::styled(" /", key),
1516 Span::raw(" Filter (! to exclude)"),
1517 ]),
1518 Line::from(vec![
1519 Span::styled(" Esc", key),
1520 Span::raw(" Clear filter "),
1521 Span::styled("Bulk Actions", heading),
1522 ]),
1523 Line::from(vec![
1524 Span::raw(" "),
1525 Span::styled("─".repeat(12), dim),
1526 ]),
1527 Line::from(vec![
1528 Span::styled(" General", heading),
1529 Span::raw(" "),
1530 Span::styled("A", key),
1531 Span::raw(" Bulk apply security"),
1532 ]),
1533 Line::from(vec![Span::styled(format!(" {}", "─".repeat(9)), dim)]),
1534 Line::from(vec![
1535 Span::styled(" q", key),
1536 Span::raw(" Quit "),
1537 Span::styled("Tabs", heading),
1538 ]),
1539 Line::from(vec![
1540 Span::styled(" r", key),
1541 Span::raw(" Reload "),
1542 Span::styled("─".repeat(4), dim),
1543 ]),
1544 Line::from(vec![
1545 Span::styled(" R", key),
1546 Span::raw(" Force reload "),
1547 Span::styled("1", key),
1548 Span::raw(" Repos"),
1549 ]),
1550 Line::from(vec![
1551 Span::styled(" ?", key),
1552 Span::raw(" Help "),
1553 Span::styled("2", key),
1554 Span::raw(" Security"),
1555 ]),
1556 Line::from(vec![
1557 Span::raw(" "),
1558 Span::styled("3", key),
1559 Span::raw(" Actions"),
1560 ]),
1561 Line::from(vec![
1562 Span::raw(" "),
1563 Span::styled("?", key),
1564 Span::raw(" Help"),
1565 ]),
1566 Line::from(""),
1567 Line::from(Span::styled(" Filter Syntax", heading)),
1568 Line::from(Span::styled(format!(" {}", "─".repeat(13)), dim)),
1569 Line::from(vec![
1570 Span::styled(" foo", key),
1571 Span::raw(" Show repos matching \"foo\""),
1572 ]),
1573 Line::from(vec![
1574 Span::styled(" !ops", key),
1575 Span::raw(" Hide repos matching \"ops\""),
1576 ]),
1577 Line::from(vec![
1578 Span::styled(" !ops !sys", key),
1579 Span::raw(" Hide \"ops\" AND \"sys\" repos"),
1580 ]),
1581 Line::from(vec![
1582 Span::styled(" foo !ops", key),
1583 Span::raw(" Show \"foo\", hide \"ops\""),
1584 ]),
1585 Line::from(""),
1586 Line::from(Span::styled(
1587 " Stack ! terms like kanban labels to narrow your view.",
1588 dim,
1589 )),
1590 Line::from(Span::styled(
1591 " E.g. !operation !workflow shows only app repos.",
1592 dim,
1593 )),
1594 ];
1595
1596 let widget = Paragraph::new(help).block(Block::default().borders(Borders::ALL).title(" Help "));
1597
1598 f.render_widget(widget, area);
1599}
1600
1601fn draw_status(f: &mut Frame, area: Rect, app: &App) {
1602 let (sys_id, sys_name) = app
1603 .systems
1604 .get(app.selected_system)
1605 .map(|(id, name)| (id.as_str(), name.as_str()))
1606 .unwrap_or(("?", "none"));
1607
1608 let sys_idx = format!("[{}/{}]", app.selected_system + 1, app.systems.len());
1609 let repo_count = if app.repos.is_empty() {
1610 String::new()
1611 } else {
1612 format!("{} repos", app.repos.len())
1613 };
1614
1615 let status_detail = if app.loading {
1616 format!("[{}] {}", app.spinner_char(), app.status_msg)
1617 } else if app.is_loading_security() {
1618 let loaded = app.repos.iter().filter(|r| r.security.is_some()).count();
1619 let total = app.repos.len();
1620 format!("[{}] Security: {loaded}/{total}...", app.spinner_char())
1621 } else {
1622 app.status_msg.clone()
1623 };
1624
1625 let mut spans = vec![
1626 Span::styled(" System: ", Style::default().fg(Color::DarkGray)),
1627 Span::styled(
1628 format!("{sys_name} ({sys_id})"),
1629 Style::default().fg(Color::Cyan),
1630 ),
1631 Span::styled(format!(" {sys_idx}"), Style::default().fg(Color::DarkGray)),
1632 ];
1633
1634 if !repo_count.is_empty() {
1635 spans.push(Span::styled(
1636 format!(" | {repo_count}"),
1637 Style::default().fg(Color::DarkGray),
1638 ));
1639 }
1640
1641 spans.extend([
1642 Span::raw(" | "),
1643 Span::styled(&status_detail, Style::default().fg(Color::DarkGray)),
1644 Span::raw(" | "),
1645 Span::styled(
1646 "q:quit Tab:sys Enter:load /:filter a/p/t:actions A:bulk",
1647 Style::default().fg(Color::DarkGray),
1648 ),
1649 ]);
1650
1651 let status = Line::from(spans);
1652 let widget = Paragraph::new(status).block(Block::default().borders(Borders::TOP));
1653
1654 f.render_widget(widget, area);
1655}