1use std::collections::HashSet;
4
5use crate::compare::{BranchCompare, CompareTab};
6use crate::git::{BlameLine, FileHistoryEntry, FilePatch, StashEntry};
7use crate::i18n::Language;
8use crate::navigation::ListNavigation;
9use crate::pr::PrCreateState;
10use crate::related_files::RelatedFiles;
11use crate::review_queue::ReviewQueue;
12use crate::stats::{
13 ChangeCouplingAnalysis, CodeOwnership, CommitImpactAnalysis, CommitQualityAnalysis,
14 FileHeatmap, ProjectHealth, RepoStats,
15};
16
17#[derive(Default)]
21pub struct StatsViewState {
22 pub cache: Option<RepoStats>,
23 pub nav: ListNavigation,
24}
25
26#[derive(Default)]
28pub struct HeatmapViewState {
29 pub cache: Option<FileHeatmap>,
30 pub nav: ListNavigation,
31}
32
33#[derive(Default)]
35pub struct FileHistoryViewState {
36 pub cache: Option<Vec<FileHistoryEntry>>,
37 pub path: Option<String>,
38 pub nav: ListNavigation,
39}
40
41#[derive(Default)]
43pub struct BlameViewState {
44 pub cache: Option<Vec<BlameLine>>,
45 pub path: Option<String>,
46 pub nav: ListNavigation,
47}
48
49#[derive(Default)]
51pub struct OwnershipViewState {
52 pub cache: Option<CodeOwnership>,
53 pub nav: ListNavigation,
54}
55
56#[derive(Default)]
58pub struct StashViewState {
59 pub cache: Option<Vec<StashEntry>>,
60 pub nav: ListNavigation,
61}
62
63#[derive(Default)]
65pub struct PatchViewState {
66 pub cache: Option<FilePatch>,
67 pub scroll_offset: usize,
68}
69
70#[derive(Default)]
72pub struct BranchCompareViewState {
73 pub cache: Option<BranchCompare>,
74 pub tab: CompareTab,
75 pub nav: ListNavigation,
76}
77
78#[derive(Default)]
80pub struct RelatedFilesViewState {
81 pub cache: Option<RelatedFiles>,
82 pub nav: ListNavigation,
83}
84
85#[derive(Default)]
87pub struct ImpactScoreViewState {
88 pub cache: Option<CommitImpactAnalysis>,
89 pub nav: ListNavigation,
90}
91
92#[derive(Default)]
94pub struct ChangeCouplingViewState {
95 pub cache: Option<ChangeCouplingAnalysis>,
96 pub nav: ListNavigation,
97}
98
99#[derive(Default)]
101pub struct QualityScoreViewState {
102 pub cache: Option<CommitQualityAnalysis>,
103 pub nav: ListNavigation,
104}
105
106#[derive(Default)]
108pub struct HealthViewState {
109 pub cache: Option<ProjectHealth>,
110}
111
112#[derive(Default)]
114pub struct ReviewQueueViewState {
115 pub cache: Option<ReviewQueue>,
116 pub nav: ListNavigation,
117}
118
119#[derive(Default)]
121pub struct PrCreateViewState(pub PrCreateState);
122
123#[derive(Default)]
125pub struct CommitDetailState {
126 pub scroll: usize,
127 pub h_scroll: usize,
128 pub selected_file: usize,
129 pub expanded_files: HashSet<usize>,
130}
131
132#[derive(Default)]
134pub struct FileDiffState {
135 pub cache: Option<FilePatch>,
136 pub(crate) cache_path: Option<String>,
137 pub scroll: usize,
138}
139
140#[derive(Debug, Clone, Copy, PartialEq)]
142pub enum StatusMessageLevel {
143 Info,
144 Success,
145 Error,
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Default)]
150pub enum InputMode {
151 #[default]
152 Normal,
153 Filter,
154 BranchSelect,
155 BranchCreate,
156 StatusView,
157 CommitInput,
158 TopologyView,
159 StatsView,
160 HeatmapView,
161 FileHistoryView,
162 TimelineView,
163 BlameView,
164 OwnershipView,
165 StashView,
166 PatchView,
167 PresetSave,
168 BranchCompareView,
169 RelatedFilesView,
170 ImpactScoreView,
171 ChangeCouplingView,
172 QualityScoreView,
173 QuickActionView,
174 ReviewQueueView,
175 PrCreate,
176}
177
178#[derive(Debug, Clone, Copy, PartialEq, Eq)]
179pub enum QuickAction {
180 RiskSummary,
181 ReviewPack,
182 NextActions,
183 Verify,
184 HandoffClaude,
185 HandoffCodex,
186 HandoffCopilot,
187 Timeline,
188 Ownership,
189 ImpactScore,
190 ChangeCoupling,
191 QualityScore,
192}
193
194impl QuickAction {
195 pub fn id(&self) -> &'static str {
196 match self {
197 Self::RiskSummary => "risk-summary",
198 Self::ReviewPack => "review-pack",
199 Self::NextActions => "next-actions",
200 Self::Verify => "verify",
201 Self::HandoffClaude => "handoff-claude",
202 Self::HandoffCodex => "handoff-codex",
203 Self::HandoffCopilot => "handoff-copilot",
204 Self::Timeline => "timeline",
205 Self::Ownership => "ownership",
206 Self::ImpactScore => "impact-score",
207 Self::ChangeCoupling => "change-coupling",
208 Self::QualityScore => "quality-score",
209 }
210 }
211
212 pub fn title(&self, lang: Language) -> &'static str {
213 match self {
214 Self::RiskSummary => lang.quick_risk_summary(),
215 Self::ReviewPack => lang.quick_review_pack(),
216 Self::NextActions => lang.quick_next_actions(),
217 Self::Verify => lang.quick_verify(),
218 Self::HandoffClaude => lang.quick_handoff_claude(),
219 Self::HandoffCodex => lang.quick_handoff_codex(),
220 Self::HandoffCopilot => lang.quick_handoff_copilot(),
221 Self::Timeline => lang.quick_timeline(),
222 Self::Ownership => lang.quick_ownership(),
223 Self::ImpactScore => lang.quick_impact_score(),
224 Self::ChangeCoupling => lang.quick_change_coupling(),
225 Self::QualityScore => lang.quick_quality_score(),
226 }
227 }
228
229 pub fn all() -> &'static [QuickAction] {
230 &[
231 Self::RiskSummary,
232 Self::ReviewPack,
233 Self::NextActions,
234 Self::Verify,
235 Self::HandoffClaude,
236 Self::HandoffCodex,
237 Self::HandoffCopilot,
238 Self::Timeline,
239 Self::Ownership,
240 Self::ImpactScore,
241 Self::ChangeCoupling,
242 Self::QualityScore,
243 ]
244 }
245}
246
247#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
249pub enum SidebarPanel {
250 #[default]
251 Commits, Status, Branches, Files, Stash, }
257
258impl SidebarPanel {
259 pub fn from_number(n: u8) -> Option<Self> {
261 match n {
262 1 => Some(Self::Status),
263 2 => Some(Self::Commits),
264 3 => Some(Self::Branches),
265 4 => Some(Self::Files),
266 5 => Some(Self::Stash),
267 _ => None,
268 }
269 }
270
271 pub fn number(&self) -> u8 {
273 match self {
274 Self::Status => 1,
275 Self::Commits => 2,
276 Self::Branches => 3,
277 Self::Files => 4,
278 Self::Stash => 5,
279 }
280 }
281
282 pub fn label(&self, lang: Language) -> &'static str {
284 match self {
285 Self::Status => lang.status(),
286 Self::Commits => lang.commits(),
287 Self::Branches => lang.branches(),
288 Self::Files => lang.files(),
289 Self::Stash => lang.stash(),
290 }
291 }
292
293 pub fn next(self) -> Self {
295 match self {
296 Self::Status => Self::Commits,
297 Self::Commits => Self::Branches,
298 Self::Branches => Self::Files,
299 Self::Files => Self::Stash,
300 Self::Stash => Self::Status,
301 }
302 }
303
304 pub fn prev(self) -> Self {
306 match self {
307 Self::Status => Self::Stash,
308 Self::Commits => Self::Status,
309 Self::Branches => Self::Commits,
310 Self::Files => Self::Branches,
311 Self::Stash => Self::Files,
312 }
313 }
314
315 pub fn all() -> &'static [SidebarPanel] {
317 &[
318 Self::Status,
319 Self::Commits,
320 Self::Branches,
321 Self::Files,
322 Self::Stash,
323 ]
324 }
325}
326
327#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
329pub enum CommitType {
330 Feat,
331 Fix,
332 Docs,
333 Style,
334 Refactor,
335 Test,
336 Chore,
337 Perf,
338}
339
340impl CommitType {
341 pub fn prefix(&self) -> &'static str {
343 match self {
344 Self::Feat => "feat: ",
345 Self::Fix => "fix: ",
346 Self::Docs => "docs: ",
347 Self::Style => "style: ",
348 Self::Refactor => "refactor: ",
349 Self::Test => "test: ",
350 Self::Chore => "chore: ",
351 Self::Perf => "perf: ",
352 }
353 }
354
355 pub fn key(&self) -> char {
357 match self {
358 Self::Feat => 'f',
359 Self::Fix => 'x',
360 Self::Docs => 'd',
361 Self::Style => 's',
362 Self::Refactor => 'r',
363 Self::Test => 't',
364 Self::Chore => 'c',
365 Self::Perf => 'p',
366 }
367 }
368
369 pub fn all() -> &'static [CommitType] {
371 &[
372 Self::Feat,
373 Self::Fix,
374 Self::Docs,
375 Self::Style,
376 Self::Refactor,
377 Self::Test,
378 Self::Chore,
379 Self::Perf,
380 ]
381 }
382
383 pub fn name(&self) -> &'static str {
385 match self {
386 Self::Feat => "feat",
387 Self::Fix => "fix",
388 Self::Docs => "docs",
389 Self::Style => "style",
390 Self::Refactor => "refactor",
391 Self::Test => "test",
392 Self::Chore => "chore",
393 Self::Perf => "perf",
394 }
395 }
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401
402 #[test]
405 fn test_stats_view_state_default() {
406 let s = StatsViewState::default();
407 assert!(s.cache.is_none());
408 assert_eq!(s.nav.selected_index, 0);
409 }
410
411 #[test]
412 fn test_heatmap_view_state_default() {
413 let s = HeatmapViewState::default();
414 assert!(s.cache.is_none());
415 }
416
417 #[test]
418 fn test_file_history_view_state_default() {
419 let s = FileHistoryViewState::default();
420 assert!(s.cache.is_none());
421 assert!(s.path.is_none());
422 }
423
424 #[test]
425 fn test_blame_view_state_default() {
426 let s = BlameViewState::default();
427 assert!(s.cache.is_none());
428 assert!(s.path.is_none());
429 }
430
431 #[test]
432 fn test_ownership_view_state_default() {
433 let s = OwnershipViewState::default();
434 assert!(s.cache.is_none());
435 }
436
437 #[test]
438 fn test_stash_view_state_default() {
439 let s = StashViewState::default();
440 assert!(s.cache.is_none());
441 }
442
443 #[test]
444 fn test_patch_view_state_default() {
445 let s = PatchViewState::default();
446 assert!(s.cache.is_none());
447 assert_eq!(s.scroll_offset, 0);
448 }
449
450 #[test]
451 fn test_branch_compare_view_state_default() {
452 let s = BranchCompareViewState::default();
453 assert!(s.cache.is_none());
454 assert_eq!(s.tab, CompareTab::default());
455 }
456
457 #[test]
458 fn test_related_files_view_state_default() {
459 let s = RelatedFilesViewState::default();
460 assert!(s.cache.is_none());
461 }
462
463 #[test]
464 fn test_impact_score_view_state_default() {
465 let s = ImpactScoreViewState::default();
466 assert!(s.cache.is_none());
467 }
468
469 #[test]
470 fn test_change_coupling_view_state_default() {
471 let s = ChangeCouplingViewState::default();
472 assert!(s.cache.is_none());
473 }
474
475 #[test]
476 fn test_quality_score_view_state_default() {
477 let s = QualityScoreViewState::default();
478 assert!(s.cache.is_none());
479 }
480
481 #[test]
482 fn test_health_view_state_default() {
483 let s = HealthViewState::default();
484 assert!(s.cache.is_none());
485 }
486
487 #[test]
488 fn test_commit_detail_state_default() {
489 let s = CommitDetailState::default();
490 assert_eq!(s.scroll, 0);
491 assert_eq!(s.h_scroll, 0);
492 assert_eq!(s.selected_file, 0);
493 assert!(s.expanded_files.is_empty());
494 }
495
496 #[test]
497 fn test_file_diff_state_default() {
498 let s = FileDiffState::default();
499 assert!(s.cache.is_none());
500 assert!(s.cache_path.is_none());
501 assert_eq!(s.scroll, 0);
502 }
503
504 #[test]
507 fn test_quick_action_all_returns_12_items() {
508 assert_eq!(QuickAction::all().len(), 12);
509 }
510
511 #[test]
512 fn test_quick_action_id_mapping() {
513 assert_eq!(QuickAction::RiskSummary.id(), "risk-summary");
514 assert_eq!(QuickAction::ReviewPack.id(), "review-pack");
515 assert_eq!(QuickAction::NextActions.id(), "next-actions");
516 assert_eq!(QuickAction::Verify.id(), "verify");
517 assert_eq!(QuickAction::HandoffClaude.id(), "handoff-claude");
518 assert_eq!(QuickAction::HandoffCodex.id(), "handoff-codex");
519 assert_eq!(QuickAction::HandoffCopilot.id(), "handoff-copilot");
520 assert_eq!(QuickAction::Timeline.id(), "timeline");
521 assert_eq!(QuickAction::Ownership.id(), "ownership");
522 assert_eq!(QuickAction::ImpactScore.id(), "impact-score");
523 assert_eq!(QuickAction::ChangeCoupling.id(), "change-coupling");
524 assert_eq!(QuickAction::QualityScore.id(), "quality-score");
525 }
526
527 #[test]
528 fn test_quick_action_title_en() {
529 let lang = Language::En;
530 for action in QuickAction::all() {
531 let title = action.title(lang);
532 assert!(!title.is_empty());
533 }
534 }
535
536 #[test]
537 fn test_quick_action_title_ja() {
538 let lang = Language::Ja;
539 for action in QuickAction::all() {
540 let title = action.title(lang);
541 assert!(!title.is_empty());
542 }
543 }
544
545 #[test]
546 fn test_quick_action_all_ids_are_unique() {
547 let ids: Vec<&str> = QuickAction::all().iter().map(|a| a.id()).collect();
548 let mut deduped = ids.clone();
549 deduped.sort();
550 deduped.dedup();
551 assert_eq!(ids.len(), deduped.len());
552 }
553
554 #[test]
557 fn test_sidebar_panel_default_is_commits() {
558 assert_eq!(SidebarPanel::default(), SidebarPanel::Commits);
559 }
560
561 #[test]
562 fn test_sidebar_panel_from_number() {
563 assert_eq!(SidebarPanel::from_number(1), Some(SidebarPanel::Status));
564 assert_eq!(SidebarPanel::from_number(2), Some(SidebarPanel::Commits));
565 assert_eq!(SidebarPanel::from_number(3), Some(SidebarPanel::Branches));
566 assert_eq!(SidebarPanel::from_number(4), Some(SidebarPanel::Files));
567 assert_eq!(SidebarPanel::from_number(5), Some(SidebarPanel::Stash));
568 assert_eq!(SidebarPanel::from_number(0), None);
569 assert_eq!(SidebarPanel::from_number(6), None);
570 }
571
572 #[test]
573 fn test_sidebar_panel_number_roundtrip() {
574 for panel in SidebarPanel::all() {
575 assert_eq!(SidebarPanel::from_number(panel.number()), Some(*panel));
576 }
577 }
578
579 #[test]
580 fn test_sidebar_panel_next_cycles() {
581 let start = SidebarPanel::Status;
582 let mut current = start;
583 let mut visited = vec![];
584 for _ in 0..5 {
585 visited.push(current);
586 current = current.next();
587 }
588 assert_eq!(visited.len(), 5);
589 assert_eq!(current, start); }
591
592 #[test]
593 fn test_sidebar_panel_prev_cycles() {
594 let start = SidebarPanel::Status;
595 let mut current = start;
596 let mut visited = vec![];
597 for _ in 0..5 {
598 visited.push(current);
599 current = current.prev();
600 }
601 assert_eq!(visited.len(), 5);
602 assert_eq!(current, start); }
604
605 #[test]
606 fn test_sidebar_panel_next_prev_inverse() {
607 for panel in SidebarPanel::all() {
608 assert_eq!(panel.next().prev(), *panel);
609 assert_eq!(panel.prev().next(), *panel);
610 }
611 }
612
613 #[test]
614 fn test_sidebar_panel_label_not_empty() {
615 for panel in SidebarPanel::all() {
616 assert!(!panel.label(Language::En).is_empty());
617 assert!(!panel.label(Language::Ja).is_empty());
618 }
619 }
620
621 #[test]
622 fn test_sidebar_panel_all_returns_5() {
623 assert_eq!(SidebarPanel::all().len(), 5);
624 }
625
626 #[test]
629 fn test_commit_type_all_returns_8() {
630 assert_eq!(CommitType::all().len(), 8);
631 }
632
633 #[test]
634 fn test_commit_type_prefix() {
635 assert_eq!(CommitType::Feat.prefix(), "feat: ");
636 assert_eq!(CommitType::Fix.prefix(), "fix: ");
637 assert_eq!(CommitType::Docs.prefix(), "docs: ");
638 assert_eq!(CommitType::Style.prefix(), "style: ");
639 assert_eq!(CommitType::Refactor.prefix(), "refactor: ");
640 assert_eq!(CommitType::Test.prefix(), "test: ");
641 assert_eq!(CommitType::Chore.prefix(), "chore: ");
642 assert_eq!(CommitType::Perf.prefix(), "perf: ");
643 }
644
645 #[test]
646 fn test_commit_type_key() {
647 assert_eq!(CommitType::Feat.key(), 'f');
648 assert_eq!(CommitType::Fix.key(), 'x');
649 assert_eq!(CommitType::Docs.key(), 'd');
650 assert_eq!(CommitType::Style.key(), 's');
651 assert_eq!(CommitType::Refactor.key(), 'r');
652 assert_eq!(CommitType::Test.key(), 't');
653 assert_eq!(CommitType::Chore.key(), 'c');
654 assert_eq!(CommitType::Perf.key(), 'p');
655 }
656
657 #[test]
658 fn test_commit_type_name() {
659 for ct in CommitType::all() {
660 let name = ct.name();
661 assert!(!name.is_empty());
662 assert!(ct.prefix().starts_with(name));
664 }
665 }
666
667 #[test]
668 fn test_commit_type_keys_are_unique() {
669 let keys: Vec<char> = CommitType::all().iter().map(|c| c.key()).collect();
670 let mut deduped = keys.clone();
671 deduped.sort();
672 deduped.dedup();
673 assert_eq!(keys.len(), deduped.len());
674 }
675
676 #[test]
679 fn test_input_mode_default_is_normal() {
680 assert_eq!(InputMode::default(), InputMode::Normal);
681 }
682}