Skip to main content

gitstack/
app.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use git2::Repository;
5
6use crate::compare::{BranchCompare, CompareTab};
7use crate::config::{FilterPreset, FilterPresets};
8use crate::event::GitEvent;
9use crate::export::{
10    export_heatmap_csv, export_heatmap_json, export_ownership_csv, export_ownership_json,
11    export_stats_csv, export_stats_json, export_timeline_csv, export_timeline_json,
12};
13use crate::filter::FilterQuery;
14use crate::git::{
15    BlameLine, CommitDiff, FileHistoryEntry, FilePatch, FileStatus, RepoInfo, StashEntry,
16};
17use crate::related_files::RelatedFiles;
18use crate::stats::{ActivityTimeline, CodeOwnership, FileHeatmap, RepoStats};
19use crate::suggestion::CommitSuggestion;
20use crate::topology::BranchTopology;
21
22/// 入力モード
23#[derive(Debug, Clone, Copy, PartialEq, Default)]
24pub enum InputMode {
25    #[default]
26    Normal,
27    Filter,
28    BranchSelect,
29    StatusView,
30    CommitInput,
31    TopologyView,
32    StatsView,
33    HeatmapView,
34    FileHistoryView,
35    TimelineView,
36    BlameView,
37    OwnershipView,
38    StashView,
39    PatchView,
40    PresetSave,
41    BranchCompareView,
42    RelatedFilesView,
43}
44
45/// 表示モード
46#[derive(Debug, Clone, Copy, PartialEq, Default)]
47pub enum ViewMode {
48    #[default]
49    Normal, // 4行/コミット
50    Compact, // 1行/コミット
51}
52
53/// レイアウトモード(画面幅に応じて自動切替)
54#[derive(Debug, Clone, Copy, PartialEq, Default)]
55pub enum LayoutMode {
56    #[default]
57    Single, // 従来の単一パネル
58    SplitPanel, // 2ペイン分割
59}
60
61/// コミットタイプ(Conventional Commits)
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
63pub enum CommitType {
64    Feat,
65    Fix,
66    Docs,
67    Style,
68    Refactor,
69    Test,
70    Chore,
71    Perf,
72}
73
74impl CommitType {
75    /// コミットメッセージのプレフィックスを取得
76    pub fn prefix(&self) -> &'static str {
77        match self {
78            Self::Feat => "feat: ",
79            Self::Fix => "fix: ",
80            Self::Docs => "docs: ",
81            Self::Style => "style: ",
82            Self::Refactor => "refactor: ",
83            Self::Test => "test: ",
84            Self::Chore => "chore: ",
85            Self::Perf => "perf: ",
86        }
87    }
88
89    /// 対応するキーを取得
90    pub fn key(&self) -> char {
91        match self {
92            Self::Feat => 'f',
93            Self::Fix => 'x',
94            Self::Docs => 'd',
95            Self::Style => 's',
96            Self::Refactor => 'r',
97            Self::Test => 't',
98            Self::Chore => 'c',
99            Self::Perf => 'p',
100        }
101    }
102
103    /// 全コミットタイプを取得
104    pub fn all() -> &'static [CommitType] {
105        &[
106            Self::Feat,
107            Self::Fix,
108            Self::Docs,
109            Self::Style,
110            Self::Refactor,
111            Self::Test,
112            Self::Chore,
113            Self::Perf,
114        ]
115    }
116
117    /// 表示名を取得
118    pub fn name(&self) -> &'static str {
119        match self {
120            Self::Feat => "feat",
121            Self::Fix => "fix",
122            Self::Docs => "docs",
123            Self::Style => "style",
124            Self::Refactor => "refactor",
125            Self::Test => "test",
126            Self::Chore => "chore",
127            Self::Perf => "perf",
128        }
129    }
130}
131
132/// アプリケーション状態
133pub struct App {
134    /// アプリケーションが終了すべきかどうか
135    pub should_quit: bool,
136    /// リポジトリ情報
137    pub repo_info: Option<RepoInfo>,
138    /// キャッシュ済みリポジトリ(パフォーマンス最適化用)
139    repo: Option<Repository>,
140    /// 全イベント一覧
141    all_events: Vec<GitEvent>,
142    /// フィルタ後のイベントインデックス
143    filtered_indices: Vec<usize>,
144    /// 選択中のイベントインデックス(filtered_indices内)
145    pub selected_index: usize,
146    /// 詳細表示モード
147    pub show_detail: bool,
148    /// 入力モード
149    pub input_mode: InputMode,
150    /// フィルタ文字列
151    pub filter_text: String,
152    /// パース済みフィルタクエリ
153    pub filter_query: FilterQuery,
154    /// ファイルキャッシュ(コミットハッシュ -> ファイル一覧)
155    file_cache: HashMap<String, Vec<String>>,
156    /// ブランチ一覧
157    pub branches: Vec<String>,
158    /// ブランチ選択インデックス
159    pub branch_selected_index: usize,
160    /// ファイルステータス一覧
161    pub file_statuses: Vec<FileStatus>,
162    /// ステータス選択インデックス
163    pub status_selected_index: usize,
164    /// コミットメッセージ
165    pub commit_message: String,
166    /// 選択されたコミットタイプ
167    pub commit_type: Option<CommitType>,
168    /// ステータスメッセージ(操作結果表示用)
169    pub status_message: Option<String>,
170    /// バックグラウンド読み込み中フラグ
171    pub is_loading: bool,
172    /// ヘルプ表示フラグ
173    pub show_help: bool,
174    /// 現在のHEADのショートハッシュ(@キージャンプ用)
175    head_hash: Option<String>,
176    /// 表示モード(Normal/Compact/Grid)
177    pub view_mode: ViewMode,
178    /// トポロジーキャッシュ
179    pub topology_cache: Option<BranchTopology>,
180    /// トポロジービューの選択インデックス
181    pub topology_selected_index: usize,
182    /// トポロジービューのスクロールオフセット
183    pub topology_scroll_offset: usize,
184
185    // ===== 提案関連 =====
186    /// コミット提案一覧
187    pub commit_suggestions: Vec<CommitSuggestion>,
188    /// 提案選択インデックス
189    pub suggestion_selected_index: usize,
190
191    // ===== 詳細ビュー関連 =====
192    /// 詳細ビューのスクロールオフセット
193    pub detail_scroll_offset: usize,
194    /// 詳細ビューで選択中のファイルインデックス
195    pub detail_selected_file: usize,
196    /// 詳細ビューのdiffキャッシュ
197    pub detail_diff_cache: Option<CommitDiff>,
198
199    // ===== 統計ビュー関連 =====
200    /// 統計情報キャッシュ
201    pub stats_cache: Option<RepoStats>,
202    /// 統計ビューで選択中の著者インデックス
203    pub stats_selected_index: usize,
204    /// 統計ビューのスクロールオフセット
205    pub stats_scroll_offset: usize,
206
207    // ===== ヒートマップビュー関連 =====
208    /// ヒートマップキャッシュ
209    pub heatmap_cache: Option<FileHeatmap>,
210    /// ヒートマップビューで選択中のファイルインデックス
211    pub heatmap_selected_index: usize,
212    /// ヒートマップビューのスクロールオフセット
213    pub heatmap_scroll_offset: usize,
214
215    // ===== ファイル履歴ビュー関連 =====
216    /// ファイル履歴キャッシュ
217    pub file_history_cache: Option<Vec<FileHistoryEntry>>,
218    /// ファイル履歴の対象ファイルパス
219    pub file_history_path: Option<String>,
220    /// ファイル履歴ビューで選択中のインデックス
221    pub file_history_selected_index: usize,
222    /// ファイル履歴ビューのスクロールオフセット
223    pub file_history_scroll_offset: usize,
224
225    // ===== タイムラインビュー関連 =====
226    /// 活動タイムラインキャッシュ
227    pub timeline_cache: Option<ActivityTimeline>,
228
229    // ===== Blameビュー関連 =====
230    /// Blameキャッシュ
231    pub blame_cache: Option<Vec<BlameLine>>,
232    /// Blame対象ファイルパス
233    pub blame_path: Option<String>,
234    /// Blameビューで選択中の行インデックス
235    pub blame_selected_index: usize,
236    /// Blameビューのスクロールオフセット
237    pub blame_scroll_offset: usize,
238
239    // ===== コードオーナーシップビュー関連 =====
240    /// コードオーナーシップキャッシュ
241    pub ownership_cache: Option<CodeOwnership>,
242    /// コードオーナーシップビューで選択中のインデックス
243    pub ownership_selected_index: usize,
244    /// コードオーナーシップビューのスクロールオフセット
245    pub ownership_scroll_offset: usize,
246
247    // ===== Stashビュー関連 =====
248    /// Stash一覧キャッシュ
249    pub stash_cache: Option<Vec<StashEntry>>,
250    /// Stashビューで選択中のインデックス
251    pub stash_selected_index: usize,
252    /// Stashビューのスクロールオフセット
253    pub stash_scroll_offset: usize,
254
255    // ===== パッチビュー関連 =====
256    /// パッチキャッシュ
257    pub patch_cache: Option<FilePatch>,
258    /// パッチビューのスクロールオフセット
259    pub patch_scroll_offset: usize,
260
261    // ===== フィルタプリセット関連 =====
262    /// フィルタプリセット
263    pub filter_presets: FilterPresets,
264    /// プリセット保存ダイアログ: 選択中のスロット (1-5)
265    pub preset_save_slot: usize,
266    /// プリセット保存ダイアログ: プリセット名入力
267    pub preset_save_name: String,
268
269    // ===== ブランチ比較ビュー関連 =====
270    /// ブランチ比較キャッシュ
271    pub branch_compare_cache: Option<BranchCompare>,
272    /// 現在のタブ (Ahead/Behind)
273    pub branch_compare_tab: CompareTab,
274    /// 選択中のインデックス
275    pub branch_compare_selected_index: usize,
276    /// スクロールオフセット
277    pub branch_compare_scroll_offset: usize,
278
279    // ===== 関連コミット優先関連 =====
280    /// 関連モードが有効かどうか
281    pub relevance_mode: bool,
282    /// ワーキングツリーの変更ファイル
283    pub working_files: Vec<String>,
284    /// 関連スコアキャッシュ(ハッシュ -> スコア)
285    relevance_scores: HashMap<String, f32>,
286
287    // ===== 関連ファイルビュー関連 =====
288    /// 関連ファイルキャッシュ
289    pub related_files_cache: Option<RelatedFiles>,
290    /// 関連ファイルビューで選択中のインデックス
291    pub related_files_selected_index: usize,
292    /// 関連ファイルビューのスクロールオフセット
293    pub related_files_scroll_offset: usize,
294
295    // ===== 大画面2ペインレイアウト関連 =====
296    /// 現在のレイアウトモード
297    pub layout_mode: LayoutMode,
298    /// 左パネルの比率(0-100)
299    pub left_panel_ratio: u16,
300}
301
302impl App {
303    pub fn new() -> Self {
304        Self {
305            should_quit: false,
306            repo_info: None,
307            repo: None,
308            all_events: Vec::new(),
309            filtered_indices: Vec::new(),
310            selected_index: 0,
311            show_detail: false,
312            input_mode: InputMode::Normal,
313            filter_text: String::new(),
314            filter_query: FilterQuery::default(),
315            file_cache: HashMap::new(),
316            branches: Vec::new(),
317            branch_selected_index: 0,
318            file_statuses: Vec::new(),
319            status_selected_index: 0,
320            commit_message: String::new(),
321            commit_type: None,
322            status_message: None,
323            is_loading: false,
324            show_help: false,
325            head_hash: None,
326            view_mode: ViewMode::default(),
327            topology_cache: None,
328            topology_selected_index: 0,
329            topology_scroll_offset: 0,
330            // 提案関連
331            commit_suggestions: Vec::new(),
332            suggestion_selected_index: 0,
333            // 詳細ビュー関連
334            detail_scroll_offset: 0,
335            detail_selected_file: 0,
336            detail_diff_cache: None,
337            // 統計ビュー関連
338            stats_cache: None,
339            stats_selected_index: 0,
340            stats_scroll_offset: 0,
341            // ヒートマップビュー関連
342            heatmap_cache: None,
343            heatmap_selected_index: 0,
344            heatmap_scroll_offset: 0,
345            // ファイル履歴ビュー関連
346            file_history_cache: None,
347            file_history_path: None,
348            file_history_selected_index: 0,
349            file_history_scroll_offset: 0,
350            // タイムラインビュー関連
351            timeline_cache: None,
352            // Blameビュー関連
353            blame_cache: None,
354            blame_path: None,
355            blame_selected_index: 0,
356            blame_scroll_offset: 0,
357            // コードオーナーシップビュー関連
358            ownership_cache: None,
359            ownership_selected_index: 0,
360            ownership_scroll_offset: 0,
361            // Stashビュー関連
362            stash_cache: None,
363            stash_selected_index: 0,
364            stash_scroll_offset: 0,
365            // パッチビュー関連
366            patch_cache: None,
367            patch_scroll_offset: 0,
368            // フィルタプリセット関連
369            filter_presets: FilterPresets::load(),
370            preset_save_slot: 1,
371            preset_save_name: String::new(),
372            // ブランチ比較ビュー関連
373            branch_compare_cache: None,
374            branch_compare_tab: CompareTab::default(),
375            branch_compare_selected_index: 0,
376            branch_compare_scroll_offset: 0,
377            // 関連コミット優先関連
378            relevance_mode: false,
379            working_files: Vec::new(),
380            relevance_scores: HashMap::new(),
381            // 関連ファイルビュー関連
382            related_files_cache: None,
383            related_files_selected_index: 0,
384            related_files_scroll_offset: 0,
385            // 大画面2ペインレイアウト関連
386            layout_mode: LayoutMode::default(),
387            left_panel_ratio: 65,
388        }
389    }
390
391    /// リポジトリ情報とイベントを設定
392    pub fn load(&mut self, repo_info: RepoInfo, events: Vec<GitEvent>) {
393        self.repo_info = Some(repo_info);
394        self.filtered_indices = (0..events.len()).collect();
395        self.all_events = events;
396        self.selected_index = 0;
397        self.filter_text.clear();
398    }
399
400    /// キャッシュ用リポジトリを設定
401    pub fn set_repo(&mut self, repo: Repository) {
402        self.repo = Some(repo);
403    }
404
405    /// キャッシュ済みリポジトリの参照を取得
406    pub fn get_repo(&self) -> Option<&Repository> {
407        self.repo.as_ref()
408    }
409
410    /// HEADハッシュを設定(@キージャンプ用)
411    pub fn set_head_hash(&mut self, hash: String) {
412        // フルハッシュの場合は7文字に切り詰め
413        self.head_hash = Some(if hash.len() > 7 {
414            hash[..7].to_string()
415        } else {
416            hash
417        });
418    }
419
420    /// HEADハッシュを取得
421    pub fn head_hash(&self) -> Option<&str> {
422        self.head_hash.as_deref()
423    }
424
425    /// フィルタ後のイベント一覧を取得
426    pub fn events(&self) -> Vec<&GitEvent> {
427        self.filtered_indices
428            .iter()
429            .filter_map(|&i| self.all_events.get(i))
430            .collect()
431    }
432
433    /// 全イベントの参照を取得
434    pub fn all_events(&self) -> &[GitEvent] {
435        &self.all_events
436    }
437
438    /// フィルタ後のイベント数を取得
439    pub fn event_count(&self) -> usize {
440        self.filtered_indices.len()
441    }
442
443    /// qキーで終了
444    pub fn quit(&mut self) {
445        self.should_quit = true;
446    }
447
448    /// 選択を上に移動
449    pub fn move_up(&mut self) {
450        if self.selected_index > 0 {
451            self.selected_index -= 1;
452        }
453    }
454
455    /// 選択を下に移動
456    pub fn move_down(&mut self) {
457        if !self.filtered_indices.is_empty()
458            && self.selected_index < self.filtered_indices.len() - 1
459        {
460            self.selected_index += 1;
461        }
462    }
463
464    /// 先頭に移動 (gg)
465    pub fn move_to_top(&mut self) {
466        self.selected_index = 0;
467    }
468
469    /// HEADにジャンプ (@キー)
470    /// フィルタ中でもHEADの位置に移動
471    pub fn jump_to_head(&mut self) {
472        // HEADのハッシュがない場合は先頭にジャンプ
473        let Some(head_hash) = &self.head_hash else {
474            self.selected_index = 0;
475            return;
476        };
477
478        // all_events内でHEADのインデックスを探す
479        let head_index = self
480            .all_events
481            .iter()
482            .position(|e| e.short_hash == *head_hash);
483
484        let Some(head_idx) = head_index else {
485            // HEADが見つからない場合は先頭にジャンプ
486            self.selected_index = 0;
487            return;
488        };
489
490        // フィルタ結果内でHEADの位置を探す
491        if let Some(pos) = self.filtered_indices.iter().position(|&i| i == head_idx) {
492            self.selected_index = pos;
493        }
494        // HEADがフィルタに含まれていない場合は何もしない
495    }
496
497    /// 末尾に移動 (G)
498    pub fn move_to_bottom(&mut self) {
499        if !self.filtered_indices.is_empty() {
500            self.selected_index = self.filtered_indices.len() - 1;
501        }
502    }
503
504    /// ページダウン (Ctrl+d)
505    pub fn page_down(&mut self, page_size: usize) {
506        if !self.filtered_indices.is_empty() {
507            let max_index = self.filtered_indices.len() - 1;
508            self.selected_index = (self.selected_index + page_size).min(max_index);
509        }
510    }
511
512    /// ページアップ (Ctrl+u)
513    pub fn page_up(&mut self, page_size: usize) {
514        self.selected_index = self.selected_index.saturating_sub(page_size);
515    }
516
517    /// ヘルプ表示を切り替え
518    pub fn toggle_help(&mut self) {
519        self.show_help = !self.show_help;
520    }
521
522    /// ヘルプを閉じる
523    pub fn close_help(&mut self) {
524        self.show_help = false;
525    }
526
527    /// 表示モードをトグル切り替え(Normal ↔ Compact)
528    pub fn cycle_view_mode(&mut self) {
529        self.view_mode = match self.view_mode {
530            ViewMode::Normal => ViewMode::Compact,
531            ViewMode::Compact => ViewMode::Normal,
532        };
533    }
534
535    /// 有効なレイアウトモードを取得(画面幅に応じて自動決定)
536    ///
537    /// 画面幅がSPLIT_PANEL_THRESHOLDを超え、かつlayout_modeがSplitPanelの場合に
538    /// 2ペインモードが有効になる。
539    pub fn effective_layout_mode(&self, width: u16) -> LayoutMode {
540        const SPLIT_PANEL_THRESHOLD: u16 = 120;
541        if width >= SPLIT_PANEL_THRESHOLD && self.layout_mode == LayoutMode::SplitPanel {
542            LayoutMode::SplitPanel
543        } else {
544            LayoutMode::Single
545        }
546    }
547
548    /// 2ペインモード時に詳細パネル用のdiffを更新
549    ///
550    /// 選択中のコミットが変わった場合にdiffキャッシュをクリアする
551    pub fn update_detail_for_split_panel(&mut self) {
552        // 2ペインモードでは常にdiffを更新する必要がある
553        // show_detailがfalseでも、選択が変わればキャッシュをクリア
554        self.detail_diff_cache = None;
555        self.detail_scroll_offset = 0;
556        self.detail_selected_file = 0;
557    }
558
559    /// レイアウトモードをトグル切り替え(Single ↔ SplitPanel)
560    pub fn toggle_layout_mode(&mut self) {
561        self.layout_mode = match self.layout_mode {
562            LayoutMode::Single => LayoutMode::SplitPanel,
563            LayoutMode::SplitPanel => LayoutMode::Single,
564        };
565    }
566
567    /// 左パネルの比率を調整
568    ///
569    /// delta: 調整値(正で拡大、負で縮小)
570    /// 比率は40%から85%の範囲に制限
571    pub fn adjust_panel_ratio(&mut self, delta: i16) {
572        let new_ratio = (self.left_panel_ratio as i16 + delta).clamp(40, 85) as u16;
573        self.left_panel_ratio = new_ratio;
574    }
575
576    /// 次のラベル付きコミットにジャンプ (] キー)
577    pub fn jump_to_next_label(&mut self) {
578        // フィルタ後のイベント内で、現在位置より後のラベル付きイベントを探す
579        for i in (self.selected_index + 1)..self.filtered_indices.len() {
580            if let Some(&event_idx) = self.filtered_indices.get(i) {
581                if let Some(event) = self.all_events.get(event_idx) {
582                    if event.has_labels() {
583                        self.selected_index = i;
584                        return;
585                    }
586                }
587            }
588        }
589        // 見つからない場合は何もしない
590    }
591
592    /// 前のラベル付きコミットにジャンプ ([ キー)
593    pub fn jump_to_prev_label(&mut self) {
594        // フィルタ後のイベント内で、現在位置より前のラベル付きイベントを探す
595        if self.selected_index == 0 {
596            return;
597        }
598        for i in (0..self.selected_index).rev() {
599            if let Some(&event_idx) = self.filtered_indices.get(i) {
600                if let Some(event) = self.all_events.get(event_idx) {
601                    if event.has_labels() {
602                        self.selected_index = i;
603                        return;
604                    }
605                }
606            }
607        }
608        // 見つからない場合は何もしない
609    }
610
611    /// 選択中のイベントを取得
612    pub fn selected_event(&self) -> Option<&GitEvent> {
613        self.filtered_indices
614            .get(self.selected_index)
615            .and_then(|&i| self.all_events.get(i))
616    }
617
618    /// 詳細表示を開く
619    pub fn open_detail(&mut self) {
620        if self.selected_event().is_some() {
621            self.show_detail = true;
622            self.detail_scroll_offset = 0;
623            self.detail_selected_file = 0;
624            self.detail_diff_cache = None;
625        }
626    }
627
628    /// 詳細表示を閉じる
629    pub fn close_detail(&mut self) {
630        self.show_detail = false;
631        self.detail_diff_cache = None;
632    }
633
634    /// 詳細ビューでdiffをキャッシュ
635    pub fn set_detail_diff(&mut self, diff: CommitDiff) {
636        self.detail_diff_cache = Some(diff);
637    }
638
639    /// 詳細ビューでファイル選択を上に移動
640    pub fn detail_move_up(&mut self) {
641        if self.detail_selected_file > 0 {
642            self.detail_selected_file -= 1;
643            // スクロールオフセットを調整
644            if self.detail_selected_file < self.detail_scroll_offset {
645                self.detail_scroll_offset = self.detail_selected_file;
646            }
647        }
648    }
649
650    /// 詳細ビューでファイル選択を下に移動
651    pub fn detail_move_down(&mut self) {
652        if let Some(ref diff) = self.detail_diff_cache {
653            if !diff.files.is_empty() && self.detail_selected_file < diff.files.len() - 1 {
654                self.detail_selected_file += 1;
655            }
656        }
657    }
658
659    /// 詳細ビューのスクロール調整(表示可能行数を考慮)
660    pub fn detail_adjust_scroll(&mut self, visible_lines: usize) {
661        if visible_lines == 0 {
662            return;
663        }
664        // 選択行が表示範囲外になったらスクロール
665        if self.detail_selected_file >= self.detail_scroll_offset + visible_lines {
666            self.detail_scroll_offset = self.detail_selected_file - visible_lines + 1;
667        }
668    }
669
670    /// フィルタモードを開始
671    pub fn start_filter(&mut self) {
672        self.input_mode = InputMode::Filter;
673    }
674
675    /// フィルタモードを終了
676    pub fn end_filter(&mut self) {
677        self.input_mode = InputMode::Normal;
678    }
679
680    /// フィルタ文字を追加
681    pub fn filter_push(&mut self, c: char) {
682        self.filter_text.push(c);
683        self.apply_filter();
684        self.update_filter_status();
685    }
686
687    /// フィルタ文字を削除
688    pub fn filter_pop(&mut self) {
689        self.filter_text.pop();
690        self.apply_filter();
691        self.update_filter_status();
692    }
693
694    /// フィルタをクリア
695    pub fn filter_clear(&mut self) {
696        self.filter_text.clear();
697        self.apply_filter();
698        self.status_message = None;
699    }
700
701    /// フィルタ適用後のステータスメッセージを更新
702    fn update_filter_status(&mut self) {
703        let total = self.all_events.len();
704        let filtered = self.filtered_indices.len();
705        if !self.filter_text.is_empty() {
706            self.status_message = Some(format!("{}/{} commits matched", filtered, total));
707        }
708    }
709
710    /// フィルタを適用
711    fn apply_filter(&mut self) {
712        // フィルタクエリをパース
713        self.filter_query = FilterQuery::parse(&self.filter_text);
714
715        if self.filter_query.is_empty() {
716            self.filtered_indices = (0..self.all_events.len()).collect();
717        } else {
718            // ハッシュ範囲フィルタがある場合は範囲インデックスを計算
719            let hash_range_indices = self.calculate_hash_range_indices();
720
721            self.filtered_indices = self
722                .all_events
723                .iter()
724                .enumerate()
725                .filter(|(i, e)| {
726                    // ハッシュ範囲フィルタの判定
727                    if let Some((start_idx, end_idx)) = hash_range_indices {
728                        // all_eventsは新しい順なので、start_idx <= i <= end_idx が範囲内
729                        if *i < start_idx || *i > end_idx {
730                            return false;
731                        }
732                    }
733
734                    let files = self.file_cache.get(&e.short_hash).map(|v| v.as_slice());
735                    self.filter_query.matches(e, files)
736                })
737                .map(|(i, _)| i)
738                .collect();
739        }
740        // 選択インデックスを調整
741        if self.selected_index >= self.filtered_indices.len() {
742            self.selected_index = self.filtered_indices.len().saturating_sub(1);
743        }
744    }
745
746    /// ハッシュ範囲のインデックスを計算
747    ///
748    /// all_eventsは時系列の新しい順なので、
749    /// 開始ハッシュ(より新しい)のインデックス <= 終了ハッシュ(より古い)のインデックス となる
750    /// 返り値: Some((開始インデックス, 終了インデックス)) または None
751    fn calculate_hash_range_indices(&self) -> Option<(usize, usize)> {
752        let (start_hash, end_hash) = self.filter_query.hash_range.as_ref()?;
753
754        // 開始ハッシュのインデックスを検索(部分一致)
755        let start_idx = self
756            .all_events
757            .iter()
758            .position(|e| e.short_hash.starts_with(start_hash))?;
759
760        // 終了ハッシュのインデックスを検索(部分一致)
761        let end_idx = self
762            .all_events
763            .iter()
764            .position(|e| e.short_hash.starts_with(end_hash))?;
765
766        // all_eventsは新しい順なので、開始(より新しい)が先、終了(より古い)が後
767        // start_idx < end_idx となるはず
768        if start_idx <= end_idx {
769            Some((start_idx, end_idx))
770        } else {
771            // 逆順の場合は入れ替え
772            Some((end_idx, start_idx))
773        }
774    }
775
776    /// ファイルキャッシュをプリロード(ファイルフィルタ有効時)
777    /// 注意: UIブロッキングを防ぐため、最新N件のみキャッシュする
778    pub fn preload_file_cache(&mut self, get_files: impl Fn(&str) -> Option<Vec<String>>) {
779        if !self.filter_query.has_file_filter() {
780            return;
781        }
782
783        // UIブロッキングを防ぐため、キャッシュするコミット数を制限
784        const MAX_PRELOAD_COMMITS: usize = 200;
785
786        for event in self.all_events.iter().take(MAX_PRELOAD_COMMITS) {
787            if !self.file_cache.contains_key(&event.short_hash) {
788                if let Some(files) = get_files(&event.short_hash) {
789                    self.file_cache.insert(event.short_hash.clone(), files);
790                }
791            }
792        }
793    }
794
795    /// ファイルキャッシュをクリア
796    pub fn clear_file_cache(&mut self) {
797        self.file_cache.clear();
798    }
799
800    /// ファイルキャッシュが空かどうか
801    pub fn file_cache_is_empty(&self) -> bool {
802        self.file_cache.is_empty()
803    }
804
805    /// フィルタを再適用(キャッシュ更新後に使用)
806    pub fn reapply_filter(&mut self) {
807        self.apply_filter();
808    }
809
810    /// リポジトリ名を取得
811    pub fn repo_name(&self) -> &str {
812        self.repo_info
813            .as_ref()
814            .map(|r| r.name.as_str())
815            .unwrap_or("unknown")
816    }
817
818    /// ブランチ名を取得
819    pub fn branch_name(&self) -> &str {
820        self.repo_info
821            .as_ref()
822            .map(|r| r.branch.as_str())
823            .unwrap_or("unknown")
824    }
825
826    /// ブランチ選択モードを開始
827    pub fn start_branch_select(&mut self, branches: Vec<String>) {
828        self.branches = branches;
829        // 現在のブランチを選択状態にする
830        let current = self.branch_name().to_string();
831        self.branch_selected_index = self
832            .branches
833            .iter()
834            .position(|b| b == &current)
835            .unwrap_or(0);
836        self.input_mode = InputMode::BranchSelect;
837    }
838
839    /// ブランチ選択モードを終了
840    pub fn end_branch_select(&mut self) {
841        self.input_mode = InputMode::Normal;
842        self.branches.clear();
843    }
844
845    /// ブランチ選択を上に移動
846    pub fn branch_move_up(&mut self) {
847        if self.branch_selected_index > 0 {
848            self.branch_selected_index -= 1;
849        }
850    }
851
852    /// ブランチ選択を下に移動
853    pub fn branch_move_down(&mut self) {
854        if !self.branches.is_empty() && self.branch_selected_index < self.branches.len() - 1 {
855            self.branch_selected_index += 1;
856        }
857    }
858
859    /// 選択中のブランチ名を取得
860    pub fn selected_branch(&self) -> Option<&str> {
861        self.branches
862            .get(self.branch_selected_index)
863            .map(|s| s.as_str())
864    }
865
866    /// ブランチ切り替え後にリポジトリ情報を更新
867    pub fn update_branch(&mut self, branch: String) {
868        if let Some(ref mut info) = self.repo_info {
869            info.branch = branch;
870        }
871    }
872
873    /// イベント一覧を置き換え
874    pub fn all_events_replace(&mut self, events: Vec<GitEvent>) {
875        self.all_events = events;
876        self.selected_index = 0;
877    }
878
879    /// フィルタインデックスをリセット
880    pub fn filtered_indices_reset(&mut self, count: usize) {
881        self.filtered_indices = (0..count).collect();
882        self.selected_index = 0;
883    }
884
885    /// ステータスビューモードを開始
886    pub fn start_status_view(&mut self, statuses: Vec<FileStatus>) {
887        self.file_statuses = statuses;
888        self.status_selected_index = 0;
889        self.status_message = None;
890        self.input_mode = InputMode::StatusView;
891    }
892
893    /// ステータスビューモードを終了
894    pub fn end_status_view(&mut self) {
895        self.input_mode = InputMode::Normal;
896        self.file_statuses.clear();
897        self.status_message = None;
898    }
899
900    /// ステータス選択を上に移動
901    pub fn status_move_up(&mut self) {
902        if self.status_selected_index > 0 {
903            self.status_selected_index -= 1;
904        }
905    }
906
907    /// ステータス選択を下に移動
908    pub fn status_move_down(&mut self) {
909        if !self.file_statuses.is_empty()
910            && self.status_selected_index < self.file_statuses.len() - 1
911        {
912            self.status_selected_index += 1;
913        }
914    }
915
916    /// 選択中のファイルステータスを取得
917    pub fn selected_file_status(&self) -> Option<&FileStatus> {
918        self.file_statuses.get(self.status_selected_index)
919    }
920
921    /// ファイルステータス一覧を更新
922    pub fn update_file_statuses(&mut self, statuses: Vec<FileStatus>) {
923        self.file_statuses = statuses;
924        if self.status_selected_index >= self.file_statuses.len() {
925            self.status_selected_index = self.file_statuses.len().saturating_sub(1);
926        }
927    }
928
929    /// ステータスメッセージを設定
930    pub fn set_status_message(&mut self, msg: String) {
931        self.status_message = Some(msg);
932    }
933
934    /// コミット入力モードを開始
935    pub fn start_commit_input(&mut self) {
936        self.commit_message.clear();
937        self.commit_type = None;
938        self.input_mode = InputMode::CommitInput;
939    }
940
941    /// コミット入力モードを終了
942    pub fn end_commit_input(&mut self) {
943        self.commit_type = None;
944        self.input_mode = InputMode::StatusView;
945    }
946
947    /// コミットタイプを選択
948    pub fn select_commit_type(&mut self, commit_type: CommitType) {
949        self.commit_type = Some(commit_type);
950        self.commit_message = commit_type.prefix().to_string();
951    }
952
953    /// コミットメッセージに文字を追加
954    pub fn commit_message_push(&mut self, c: char) {
955        self.commit_message.push(c);
956    }
957
958    /// コミットメッセージから文字を削除
959    pub fn commit_message_pop(&mut self) {
960        self.commit_message.pop();
961        // プレフィックスが消えたらタイプもクリア
962        if let Some(commit_type) = self.commit_type {
963            if !self
964                .commit_message
965                .starts_with(commit_type.prefix().trim_end())
966            {
967                self.commit_type = None;
968            }
969        }
970    }
971
972    /// コミットメッセージをクリア
973    pub fn commit_message_clear(&mut self) {
974        self.commit_message.clear();
975        self.commit_type = None;
976    }
977
978    /// 読み込み中状態を開始
979    pub fn start_loading(&mut self) {
980        self.is_loading = true;
981    }
982
983    /// 読み込み中状態を終了
984    pub fn finish_loading(&mut self) {
985        self.is_loading = false;
986    }
987
988    /// イベントを追加(バックグラウンド読み込み用)
989    pub fn append_events(&mut self, mut events: Vec<GitEvent>) {
990        let current_len = self.all_events.len();
991        self.all_events.append(&mut events);
992        // フィルタが適用されていなければインデックスを追加
993        if self.filter_text.is_empty() {
994            let new_indices: Vec<usize> = (current_len..self.all_events.len()).collect();
995            self.filtered_indices.extend(new_indices);
996        } else {
997            // フィルタ適用中なら再適用
998            self.apply_filter();
999        }
1000    }
1001
1002    // ===== トポロジービュー関連 =====
1003
1004    /// トポロジービューを開始
1005    pub fn start_topology_view(&mut self, topology: BranchTopology) {
1006        self.topology_cache = Some(topology);
1007        self.topology_selected_index = 0;
1008        self.topology_scroll_offset = 0;
1009        self.input_mode = InputMode::TopologyView;
1010    }
1011
1012    /// トポロジービューを終了
1013    pub fn end_topology_view(&mut self) {
1014        self.input_mode = InputMode::Normal;
1015        self.topology_cache = None;
1016    }
1017
1018    /// トポロジー選択を上に移動
1019    pub fn topology_move_up(&mut self) {
1020        if self.topology_selected_index > 0 {
1021            self.topology_selected_index -= 1;
1022            // スクロールオフセットを調整
1023            if self.topology_selected_index < self.topology_scroll_offset {
1024                self.topology_scroll_offset = self.topology_selected_index;
1025            }
1026        }
1027    }
1028
1029    /// トポロジー選択を下に移動
1030    pub fn topology_move_down(&mut self) {
1031        if let Some(ref topology) = self.topology_cache {
1032            if self.topology_selected_index < topology.branches.len().saturating_sub(1) {
1033                self.topology_selected_index += 1;
1034            }
1035        }
1036    }
1037
1038    /// トポロジービューの表示可能行数を設定してスクロールを調整
1039    pub fn topology_adjust_scroll(&mut self, visible_lines: usize) {
1040        if visible_lines == 0 {
1041            return;
1042        }
1043        // 選択行が表示範囲外になったらスクロール
1044        if self.topology_selected_index >= self.topology_scroll_offset + visible_lines {
1045            self.topology_scroll_offset = self.topology_selected_index - visible_lines + 1;
1046        }
1047    }
1048
1049    /// 選択中のトポロジーブランチ名を取得
1050    pub fn selected_topology_branch(&self) -> Option<&str> {
1051        self.topology_cache.as_ref().and_then(|t| {
1052            t.branches
1053                .get(self.topology_selected_index)
1054                .map(|b| b.name.as_str())
1055        })
1056    }
1057
1058    /// フィルタクエリの説明を取得
1059    pub fn filter_description(&self) -> String {
1060        self.filter_query.description()
1061    }
1062
1063    // ===== 提案関連 =====
1064
1065    /// コミット提案を生成
1066    pub fn generate_commit_suggestions(&mut self) {
1067        use crate::suggestion::generate_suggestions;
1068
1069        let staged: Vec<FileStatus> = self
1070            .file_statuses
1071            .iter()
1072            .filter(|s| s.kind.is_staged())
1073            .cloned()
1074            .collect();
1075        self.commit_suggestions = generate_suggestions(&staged);
1076        self.suggestion_selected_index = 0;
1077    }
1078
1079    /// 提案を適用
1080    pub fn apply_suggestion(&mut self, index: usize) {
1081        if let Some(suggestion) = self.commit_suggestions.get(index) {
1082            self.commit_type = Some(suggestion.commit_type);
1083            self.commit_message = suggestion.full_message();
1084        }
1085    }
1086
1087    /// 提案選択を上に移動
1088    pub fn suggestion_move_up(&mut self) {
1089        if self.suggestion_selected_index > 0 {
1090            self.suggestion_selected_index -= 1;
1091        }
1092    }
1093
1094    /// 提案選択を下に移動
1095    pub fn suggestion_move_down(&mut self) {
1096        if !self.commit_suggestions.is_empty()
1097            && self.suggestion_selected_index < self.commit_suggestions.len() - 1
1098        {
1099            self.suggestion_selected_index += 1;
1100        }
1101    }
1102
1103    // ===== 統計ビュー関連 =====
1104
1105    /// 統計ビューを開始
1106    pub fn start_stats_view(&mut self, stats: RepoStats) {
1107        self.stats_cache = Some(stats);
1108        self.stats_selected_index = 0;
1109        self.stats_scroll_offset = 0;
1110        self.input_mode = InputMode::StatsView;
1111    }
1112
1113    /// 統計ビューを終了
1114    pub fn end_stats_view(&mut self) {
1115        self.input_mode = InputMode::Normal;
1116        self.stats_cache = None;
1117    }
1118
1119    /// 統計情報をエクスポート
1120    pub fn export_stats(&mut self) -> Option<String> {
1121        if let Some(ref stats) = self.stats_cache {
1122            let csv_path = Path::new("gitstack_stats.csv");
1123            let json_path = Path::new("gitstack_stats.json");
1124
1125            let mut results = Vec::new();
1126
1127            if let Err(e) = export_stats_csv(stats, csv_path) {
1128                results.push(format!("CSV error: {}", e));
1129            } else {
1130                results.push(format!("CSV: {}", csv_path.display()));
1131            }
1132
1133            if let Err(e) = export_stats_json(stats, json_path) {
1134                results.push(format!("JSON error: {}", e));
1135            } else {
1136                results.push(format!("JSON: {}", json_path.display()));
1137            }
1138
1139            let message = format!("Exported: {}", results.join(", "));
1140            self.status_message = Some(message.clone());
1141            Some(message)
1142        } else {
1143            None
1144        }
1145    }
1146
1147    /// 統計ビューで選択を上に移動
1148    pub fn stats_move_up(&mut self) {
1149        if self.stats_selected_index > 0 {
1150            self.stats_selected_index -= 1;
1151            // スクロールオフセットを調整
1152            if self.stats_selected_index < self.stats_scroll_offset {
1153                self.stats_scroll_offset = self.stats_selected_index;
1154            }
1155        }
1156    }
1157
1158    /// 統計ビューで選択を下に移動
1159    pub fn stats_move_down(&mut self) {
1160        if let Some(ref stats) = self.stats_cache {
1161            if !stats.authors.is_empty() && self.stats_selected_index < stats.authors.len() - 1 {
1162                self.stats_selected_index += 1;
1163            }
1164        }
1165    }
1166
1167    /// 統計ビューのスクロール調整
1168    pub fn stats_adjust_scroll(&mut self, visible_lines: usize) {
1169        if visible_lines == 0 {
1170            return;
1171        }
1172        if self.stats_selected_index >= self.stats_scroll_offset + visible_lines {
1173            self.stats_scroll_offset = self.stats_selected_index - visible_lines + 1;
1174        }
1175    }
1176
1177    /// 選択中の著者名を取得
1178    pub fn selected_author(&self) -> Option<&str> {
1179        self.stats_cache.as_ref().and_then(|s| {
1180            s.authors
1181                .get(self.stats_selected_index)
1182                .map(|a| a.name.as_str())
1183        })
1184    }
1185
1186    /// 著者でフィルタを適用
1187    pub fn filter_by_author(&mut self, author: &str) {
1188        self.filter_text = format!("/author:{}", author);
1189        self.reapply_filter();
1190    }
1191
1192    // ===== ヒートマップビュー関連 =====
1193
1194    /// ヒートマップビューを開始
1195    pub fn start_heatmap_view(&mut self, heatmap: FileHeatmap) {
1196        self.heatmap_cache = Some(heatmap);
1197        self.heatmap_selected_index = 0;
1198        self.heatmap_scroll_offset = 0;
1199        self.input_mode = InputMode::HeatmapView;
1200    }
1201
1202    /// ヒートマップビューを終了
1203    pub fn end_heatmap_view(&mut self) {
1204        self.input_mode = InputMode::Normal;
1205        self.heatmap_cache = None;
1206    }
1207
1208    /// ヒートマップをエクスポート
1209    pub fn export_heatmap(&mut self) -> Option<String> {
1210        if let Some(ref heatmap) = self.heatmap_cache {
1211            let csv_path = Path::new("gitstack_heatmap.csv");
1212            let json_path = Path::new("gitstack_heatmap.json");
1213
1214            let mut results = Vec::new();
1215
1216            if let Err(e) = export_heatmap_csv(heatmap, csv_path) {
1217                results.push(format!("CSV error: {}", e));
1218            } else {
1219                results.push(format!("CSV: {}", csv_path.display()));
1220            }
1221
1222            if let Err(e) = export_heatmap_json(heatmap, json_path) {
1223                results.push(format!("JSON error: {}", e));
1224            } else {
1225                results.push(format!("JSON: {}", json_path.display()));
1226            }
1227
1228            let message = format!("Exported: {}", results.join(", "));
1229            self.status_message = Some(message.clone());
1230            Some(message)
1231        } else {
1232            None
1233        }
1234    }
1235
1236    /// ヒートマップの集約レベルを次へ切り替え(+キー)
1237    pub fn heatmap_increase_aggregation(&mut self) {
1238        if let Some(ref heatmap) = self.heatmap_cache.clone() {
1239            let new_level = heatmap.aggregation_level.next();
1240            self.heatmap_cache = Some(heatmap.with_aggregation(new_level));
1241            self.heatmap_selected_index = 0;
1242            self.heatmap_scroll_offset = 0;
1243            self.status_message = Some(format!("Aggregation: {}", new_level.display_name()));
1244        }
1245    }
1246
1247    /// ヒートマップの集約レベルを前へ切り替え(-キー)
1248    pub fn heatmap_decrease_aggregation(&mut self) {
1249        if let Some(ref heatmap) = self.heatmap_cache.clone() {
1250            let new_level = heatmap.aggregation_level.prev();
1251            self.heatmap_cache = Some(heatmap.with_aggregation(new_level));
1252            self.heatmap_selected_index = 0;
1253            self.heatmap_scroll_offset = 0;
1254            self.status_message = Some(format!("Aggregation: {}", new_level.display_name()));
1255        }
1256    }
1257
1258    /// ヒートマップビューで選択を上に移動
1259    pub fn heatmap_move_up(&mut self) {
1260        if self.heatmap_selected_index > 0 {
1261            self.heatmap_selected_index -= 1;
1262            // スクロールオフセットを調整
1263            if self.heatmap_selected_index < self.heatmap_scroll_offset {
1264                self.heatmap_scroll_offset = self.heatmap_selected_index;
1265            }
1266        }
1267    }
1268
1269    /// ヒートマップビューで選択を下に移動
1270    pub fn heatmap_move_down(&mut self) {
1271        if let Some(ref heatmap) = self.heatmap_cache {
1272            if !heatmap.files.is_empty() && self.heatmap_selected_index < heatmap.files.len() - 1 {
1273                self.heatmap_selected_index += 1;
1274            }
1275        }
1276    }
1277
1278    /// ヒートマップビューのスクロール調整
1279    pub fn heatmap_adjust_scroll(&mut self, visible_lines: usize) {
1280        if visible_lines == 0 {
1281            return;
1282        }
1283        if self.heatmap_selected_index >= self.heatmap_scroll_offset + visible_lines {
1284            self.heatmap_scroll_offset = self.heatmap_selected_index - visible_lines + 1;
1285        }
1286    }
1287
1288    /// 選択中のファイルパスを取得
1289    pub fn selected_heatmap_file(&self) -> Option<&str> {
1290        self.heatmap_cache.as_ref().and_then(|h| {
1291            h.files
1292                .get(self.heatmap_selected_index)
1293                .map(|f| f.path.as_str())
1294        })
1295    }
1296
1297    /// ファイルでフィルタを適用
1298    pub fn filter_by_file(&mut self, file_path: &str) {
1299        self.filter_text = format!("/file:{}", file_path);
1300        self.reapply_filter();
1301    }
1302
1303    // ===== ファイル履歴ビュー関連 =====
1304
1305    /// ファイル履歴ビューを開始
1306    pub fn start_file_history_view(&mut self, path: String, history: Vec<FileHistoryEntry>) {
1307        self.file_history_path = Some(path);
1308        self.file_history_cache = Some(history);
1309        self.file_history_selected_index = 0;
1310        self.file_history_scroll_offset = 0;
1311        self.input_mode = InputMode::FileHistoryView;
1312    }
1313
1314    /// ファイル履歴ビューを終了
1315    pub fn end_file_history_view(&mut self) {
1316        self.input_mode = InputMode::Normal;
1317        self.file_history_cache = None;
1318        self.file_history_path = None;
1319    }
1320
1321    /// ファイル履歴ビューで選択を上に移動
1322    pub fn file_history_move_up(&mut self) {
1323        if self.file_history_selected_index > 0 {
1324            self.file_history_selected_index -= 1;
1325            // スクロールオフセットを調整
1326            if self.file_history_selected_index < self.file_history_scroll_offset {
1327                self.file_history_scroll_offset = self.file_history_selected_index;
1328            }
1329        }
1330    }
1331
1332    /// ファイル履歴ビューで選択を下に移動
1333    pub fn file_history_move_down(&mut self) {
1334        if let Some(ref history) = self.file_history_cache {
1335            if !history.is_empty() && self.file_history_selected_index < history.len() - 1 {
1336                self.file_history_selected_index += 1;
1337            }
1338        }
1339    }
1340
1341    /// ファイル履歴ビューのスクロール調整
1342    pub fn file_history_adjust_scroll(&mut self, visible_lines: usize) {
1343        if visible_lines == 0 {
1344            return;
1345        }
1346        if self.file_history_selected_index >= self.file_history_scroll_offset + visible_lines {
1347            self.file_history_scroll_offset = self.file_history_selected_index - visible_lines + 1;
1348        }
1349    }
1350
1351    /// 選択中のファイル履歴エントリを取得
1352    pub fn selected_file_history(&self) -> Option<&FileHistoryEntry> {
1353        self.file_history_cache
1354            .as_ref()
1355            .and_then(|h| h.get(self.file_history_selected_index))
1356    }
1357
1358    // ===== タイムラインビュー関連 =====
1359
1360    /// タイムラインビューを開始
1361    pub fn start_timeline_view(&mut self, timeline: ActivityTimeline) {
1362        self.timeline_cache = Some(timeline);
1363        self.input_mode = InputMode::TimelineView;
1364    }
1365
1366    /// タイムラインビューを終了
1367    pub fn end_timeline_view(&mut self) {
1368        self.input_mode = InputMode::Normal;
1369        self.timeline_cache = None;
1370    }
1371
1372    /// タイムラインをエクスポート
1373    pub fn export_timeline(&mut self) -> Option<String> {
1374        if let Some(ref timeline) = self.timeline_cache {
1375            let csv_path = Path::new("gitstack_timeline.csv");
1376            let json_path = Path::new("gitstack_timeline.json");
1377
1378            let mut results = Vec::new();
1379
1380            if let Err(e) = export_timeline_csv(timeline, csv_path) {
1381                results.push(format!("CSV error: {}", e));
1382            } else {
1383                results.push(format!("CSV: {}", csv_path.display()));
1384            }
1385
1386            if let Err(e) = export_timeline_json(timeline, json_path) {
1387                results.push(format!("JSON error: {}", e));
1388            } else {
1389                results.push(format!("JSON: {}", json_path.display()));
1390            }
1391
1392            let message = format!("Exported: {}", results.join(", "));
1393            self.status_message = Some(message.clone());
1394            Some(message)
1395        } else {
1396            None
1397        }
1398    }
1399
1400    // ===== Blameビュー関連 =====
1401
1402    /// Blameビューを開始
1403    pub fn start_blame_view(&mut self, path: String, blame: Vec<BlameLine>) {
1404        self.blame_path = Some(path);
1405        self.blame_cache = Some(blame);
1406        self.blame_selected_index = 0;
1407        self.blame_scroll_offset = 0;
1408        self.input_mode = InputMode::BlameView;
1409    }
1410
1411    /// Blameビューを終了
1412    pub fn end_blame_view(&mut self) {
1413        self.input_mode = InputMode::Normal;
1414        self.blame_cache = None;
1415        self.blame_path = None;
1416    }
1417
1418    /// Blameビューで選択を上に移動
1419    pub fn blame_move_up(&mut self) {
1420        if self.blame_selected_index > 0 {
1421            self.blame_selected_index -= 1;
1422            // スクロールオフセットを調整
1423            if self.blame_selected_index < self.blame_scroll_offset {
1424                self.blame_scroll_offset = self.blame_selected_index;
1425            }
1426        }
1427    }
1428
1429    /// Blameビューで選択を下に移動
1430    pub fn blame_move_down(&mut self) {
1431        if let Some(ref blame) = self.blame_cache {
1432            if !blame.is_empty() && self.blame_selected_index < blame.len() - 1 {
1433                self.blame_selected_index += 1;
1434            }
1435        }
1436    }
1437
1438    /// Blameビューのスクロール調整
1439    pub fn blame_adjust_scroll(&mut self, visible_lines: usize) {
1440        if visible_lines == 0 {
1441            return;
1442        }
1443        if self.blame_selected_index >= self.blame_scroll_offset + visible_lines {
1444            self.blame_scroll_offset = self.blame_selected_index - visible_lines + 1;
1445        }
1446    }
1447
1448    /// 選択中のBlame行を取得
1449    pub fn selected_blame_line(&self) -> Option<&BlameLine> {
1450        self.blame_cache
1451            .as_ref()
1452            .and_then(|b| b.get(self.blame_selected_index))
1453    }
1454
1455    // ===== コードオーナーシップビュー関連 =====
1456
1457    /// コードオーナーシップビューを開始
1458    pub fn start_ownership_view(&mut self, ownership: CodeOwnership) {
1459        self.ownership_cache = Some(ownership);
1460        self.ownership_selected_index = 0;
1461        self.ownership_scroll_offset = 0;
1462        self.input_mode = InputMode::OwnershipView;
1463    }
1464
1465    /// コードオーナーシップビューを終了
1466    pub fn end_ownership_view(&mut self) {
1467        self.input_mode = InputMode::Normal;
1468        self.ownership_cache = None;
1469    }
1470
1471    /// コードオーナーシップをエクスポート
1472    pub fn export_ownership(&mut self) -> Option<String> {
1473        if let Some(ref ownership) = self.ownership_cache {
1474            let csv_path = Path::new("gitstack_ownership.csv");
1475            let json_path = Path::new("gitstack_ownership.json");
1476
1477            let mut results = Vec::new();
1478
1479            if let Err(e) = export_ownership_csv(ownership, csv_path) {
1480                results.push(format!("CSV error: {}", e));
1481            } else {
1482                results.push(format!("CSV: {}", csv_path.display()));
1483            }
1484
1485            if let Err(e) = export_ownership_json(ownership, json_path) {
1486                results.push(format!("JSON error: {}", e));
1487            } else {
1488                results.push(format!("JSON: {}", json_path.display()));
1489            }
1490
1491            let message = format!("Exported: {}", results.join(", "));
1492            self.status_message = Some(message.clone());
1493            Some(message)
1494        } else {
1495            None
1496        }
1497    }
1498
1499    /// コードオーナーシップビューで選択を上に移動
1500    pub fn ownership_move_up(&mut self) {
1501        if self.ownership_selected_index > 0 {
1502            self.ownership_selected_index -= 1;
1503            // スクロールオフセットを調整
1504            if self.ownership_selected_index < self.ownership_scroll_offset {
1505                self.ownership_scroll_offset = self.ownership_selected_index;
1506            }
1507        }
1508    }
1509
1510    /// コードオーナーシップビューで選択を下に移動
1511    pub fn ownership_move_down(&mut self) {
1512        if let Some(ref ownership) = self.ownership_cache {
1513            if !ownership.entries.is_empty()
1514                && self.ownership_selected_index < ownership.entries.len() - 1
1515            {
1516                self.ownership_selected_index += 1;
1517            }
1518        }
1519    }
1520
1521    /// コードオーナーシップビューのスクロール調整
1522    pub fn ownership_adjust_scroll(&mut self, visible_lines: usize) {
1523        if visible_lines == 0 {
1524            return;
1525        }
1526        if self.ownership_selected_index >= self.ownership_scroll_offset + visible_lines {
1527            self.ownership_scroll_offset = self.ownership_selected_index - visible_lines + 1;
1528        }
1529    }
1530
1531    /// 選択中のオーナーシップエントリを取得
1532    pub fn selected_ownership_entry(&self) -> Option<&crate::stats::CodeOwnershipEntry> {
1533        self.ownership_cache
1534            .as_ref()
1535            .and_then(|o| o.entries.get(self.ownership_selected_index))
1536    }
1537
1538    // ===== Stashビュー関連メソッド =====
1539
1540    /// Stashビューを開始
1541    pub fn start_stash_view(&mut self, stashes: Vec<StashEntry>) {
1542        self.stash_cache = Some(stashes);
1543        self.stash_selected_index = 0;
1544        self.stash_scroll_offset = 0;
1545        self.input_mode = InputMode::StashView;
1546    }
1547
1548    /// Stashビューを終了
1549    pub fn end_stash_view(&mut self) {
1550        self.input_mode = InputMode::Normal;
1551        self.stash_cache = None;
1552    }
1553
1554    /// Stashビューで選択を上に移動
1555    pub fn stash_move_up(&mut self) {
1556        if self.stash_selected_index > 0 {
1557            self.stash_selected_index -= 1;
1558            // スクロールオフセットを調整
1559            if self.stash_selected_index < self.stash_scroll_offset {
1560                self.stash_scroll_offset = self.stash_selected_index;
1561            }
1562        }
1563    }
1564
1565    /// Stashビューで選択を下に移動
1566    pub fn stash_move_down(&mut self) {
1567        if let Some(ref stashes) = self.stash_cache {
1568            if !stashes.is_empty() && self.stash_selected_index < stashes.len() - 1 {
1569                self.stash_selected_index += 1;
1570            }
1571        }
1572    }
1573
1574    /// Stashビューのスクロール調整
1575    pub fn stash_adjust_scroll(&mut self, visible_lines: usize) {
1576        if visible_lines == 0 {
1577            return;
1578        }
1579        if self.stash_selected_index >= self.stash_scroll_offset + visible_lines {
1580            self.stash_scroll_offset = self.stash_selected_index - visible_lines + 1;
1581        }
1582    }
1583
1584    /// 選択中のStashエントリを取得
1585    pub fn selected_stash_entry(&self) -> Option<&StashEntry> {
1586        self.stash_cache
1587            .as_ref()
1588            .and_then(|s| s.get(self.stash_selected_index))
1589    }
1590
1591    /// Stash一覧を更新
1592    pub fn update_stash_cache(&mut self, stashes: Vec<StashEntry>) {
1593        let prev_len = self.stash_cache.as_ref().map(|s| s.len()).unwrap_or(0);
1594        self.stash_cache = Some(stashes);
1595        // 選択インデックスを調整(削除後など)
1596        if let Some(ref stashes) = self.stash_cache {
1597            if !stashes.is_empty() && self.stash_selected_index >= stashes.len() {
1598                self.stash_selected_index = stashes.len() - 1;
1599            }
1600            // 空になった場合はインデックスを0に
1601            if stashes.is_empty() {
1602                self.stash_selected_index = 0;
1603            }
1604        }
1605        let _ = prev_len; // suppress unused warning
1606    }
1607
1608    // ===== パッチビュー関連メソッド =====
1609
1610    /// パッチビューを開始
1611    pub fn start_patch_view(&mut self, patch: FilePatch) {
1612        self.patch_cache = Some(patch);
1613        self.patch_scroll_offset = 0;
1614        self.input_mode = InputMode::PatchView;
1615    }
1616
1617    /// パッチビューを終了
1618    pub fn end_patch_view(&mut self) {
1619        self.input_mode = InputMode::Normal;
1620        self.show_detail = true; // 詳細ビューに戻る
1621        self.patch_cache = None;
1622    }
1623
1624    /// パッチビューで上にスクロール
1625    pub fn patch_scroll_up(&mut self) {
1626        if self.patch_scroll_offset > 0 {
1627            self.patch_scroll_offset -= 1;
1628        }
1629    }
1630
1631    /// パッチビューで下にスクロール
1632    pub fn patch_scroll_down(&mut self, visible_lines: usize) {
1633        if let Some(ref patch) = self.patch_cache {
1634            let max_scroll = patch.lines.len().saturating_sub(visible_lines);
1635            if self.patch_scroll_offset < max_scroll {
1636                self.patch_scroll_offset += 1;
1637            }
1638        }
1639    }
1640
1641    // ===== フィルタプリセット関連 =====
1642
1643    /// フィルタプリセットを適用(1-5キー)
1644    pub fn apply_preset(&mut self, slot: usize) {
1645        if let Some(preset) = self.filter_presets.get(slot) {
1646            let query = preset.query.clone();
1647            let name = preset.name.clone();
1648            self.filter_text = query;
1649            self.apply_filter();
1650            self.set_status_message(format!("Preset {}: {}", slot, name));
1651        }
1652    }
1653
1654    /// プリセット保存ダイアログを開始(Ctrl+S)
1655    pub fn start_preset_save(&mut self) {
1656        if self.filter_text.is_empty() {
1657            self.set_status_message("No filter to save".to_string());
1658            return;
1659        }
1660        // 空きスロットまたはスロット1を選択
1661        self.preset_save_slot = self.filter_presets.next_empty_slot().unwrap_or(1);
1662        self.preset_save_name.clear();
1663        self.input_mode = InputMode::PresetSave;
1664    }
1665
1666    /// プリセット保存ダイアログを終了
1667    pub fn end_preset_save(&mut self) {
1668        self.input_mode = InputMode::Filter;
1669        self.preset_save_name.clear();
1670    }
1671
1672    /// プリセット保存: スロット選択を上に移動
1673    pub fn preset_slot_up(&mut self) {
1674        if self.preset_save_slot > 1 {
1675            self.preset_save_slot -= 1;
1676        }
1677    }
1678
1679    /// プリセット保存: スロット選択を下に移動
1680    pub fn preset_slot_down(&mut self) {
1681        if self.preset_save_slot < 5 {
1682            self.preset_save_slot += 1;
1683        }
1684    }
1685
1686    /// プリセット保存: 名前入力に文字を追加
1687    pub fn preset_name_push(&mut self, c: char) {
1688        self.preset_save_name.push(c);
1689    }
1690
1691    /// プリセット保存: 名前入力から文字を削除
1692    pub fn preset_name_pop(&mut self) {
1693        self.preset_save_name.pop();
1694    }
1695
1696    /// プリセットを保存
1697    pub fn save_preset(&mut self) -> bool {
1698        if self.filter_text.is_empty() {
1699            return false;
1700        }
1701
1702        let name = if self.preset_save_name.is_empty() {
1703            format!("Preset {}", self.preset_save_slot)
1704        } else {
1705            self.preset_save_name.clone()
1706        };
1707
1708        self.filter_presets.set(
1709            self.preset_save_slot,
1710            FilterPreset {
1711                name: name.clone(),
1712                query: self.filter_text.clone(),
1713            },
1714        );
1715
1716        if self.filter_presets.save().is_ok() {
1717            self.set_status_message(format!("Saved to slot {}: {}", self.preset_save_slot, name));
1718            true
1719        } else {
1720            self.set_status_message("Failed to save preset".to_string());
1721            false
1722        }
1723    }
1724
1725    /// プリセットを削除
1726    pub fn delete_preset(&mut self, slot: usize) {
1727        match slot {
1728            1 => self.filter_presets.slot1 = None,
1729            2 => self.filter_presets.slot2 = None,
1730            3 => self.filter_presets.slot3 = None,
1731            4 => self.filter_presets.slot4 = None,
1732            5 => self.filter_presets.slot5 = None,
1733            _ => return,
1734        }
1735        if self.filter_presets.save().is_ok() {
1736            self.set_status_message(format!("Deleted preset {}", slot));
1737        }
1738    }
1739
1740    // ===== ブランチ比較ビュー関連 =====
1741
1742    /// ブランチ比較ビューを開始
1743    pub fn start_branch_compare_view(&mut self, compare: BranchCompare) {
1744        self.branch_compare_cache = Some(compare);
1745        self.branch_compare_tab = CompareTab::Ahead;
1746        self.branch_compare_selected_index = 0;
1747        self.branch_compare_scroll_offset = 0;
1748        self.input_mode = InputMode::BranchCompareView;
1749    }
1750
1751    /// ブランチ比較ビューを終了
1752    pub fn end_branch_compare_view(&mut self) {
1753        self.branch_compare_cache = None;
1754        self.input_mode = InputMode::TopologyView;
1755    }
1756
1757    /// タブを切り替え
1758    pub fn branch_compare_toggle_tab(&mut self) {
1759        self.branch_compare_tab = self.branch_compare_tab.toggle();
1760        self.branch_compare_selected_index = 0;
1761        self.branch_compare_scroll_offset = 0;
1762    }
1763
1764    /// 選択を上に移動
1765    pub fn branch_compare_move_up(&mut self) {
1766        if self.branch_compare_selected_index > 0 {
1767            self.branch_compare_selected_index -= 1;
1768            if self.branch_compare_selected_index < self.branch_compare_scroll_offset {
1769                self.branch_compare_scroll_offset = self.branch_compare_selected_index;
1770            }
1771        }
1772    }
1773
1774    /// 選択を下に移動
1775    pub fn branch_compare_move_down(&mut self) {
1776        if let Some(ref compare) = self.branch_compare_cache {
1777            let count = match self.branch_compare_tab {
1778                CompareTab::Ahead => compare.ahead_commits.len(),
1779                CompareTab::Behind => compare.behind_commits.len(),
1780            };
1781            if count > 0 && self.branch_compare_selected_index < count - 1 {
1782                self.branch_compare_selected_index += 1;
1783            }
1784        }
1785    }
1786
1787    /// スクロールを調整
1788    pub fn branch_compare_adjust_scroll(&mut self, visible_lines: usize) {
1789        if visible_lines == 0 {
1790            return;
1791        }
1792        if self.branch_compare_selected_index >= self.branch_compare_scroll_offset + visible_lines {
1793            self.branch_compare_scroll_offset =
1794                self.branch_compare_selected_index - visible_lines + 1;
1795        }
1796    }
1797
1798    /// 現在選択中のコミットを取得
1799    pub fn selected_branch_compare_commit(&self) -> Option<&crate::compare::CompareCommit> {
1800        let compare = self.branch_compare_cache.as_ref()?;
1801        let commits = match self.branch_compare_tab {
1802            CompareTab::Ahead => &compare.ahead_commits,
1803            CompareTab::Behind => &compare.behind_commits,
1804        };
1805        commits.get(self.branch_compare_selected_index)
1806    }
1807
1808    // ===== 関連コミット優先関連 =====
1809
1810    /// 関連モードをトグル
1811    pub fn toggle_relevance_mode(&mut self) {
1812        self.relevance_mode = !self.relevance_mode;
1813        if self.relevance_mode {
1814            self.apply_relevance_sort();
1815        } else {
1816            // 通常モードに戻す場合はフィルタを再適用
1817            self.reapply_filter();
1818        }
1819    }
1820
1821    /// ワーキングファイルを設定
1822    pub fn set_working_files(&mut self, files: Vec<String>) {
1823        self.working_files = files;
1824        // 関連モードが有効な場合は再ソート
1825        if self.relevance_mode {
1826            self.apply_relevance_sort();
1827        }
1828    }
1829
1830    /// 関連スコアを計算してソート適用
1831    pub fn apply_relevance_sort(&mut self) {
1832        if self.working_files.is_empty() {
1833            self.set_status_message("No working files to compare".to_string());
1834            self.relevance_mode = false;
1835            return;
1836        }
1837
1838        // 関連スコアを計算
1839        let events: Vec<&GitEvent> = self.all_events.iter().collect();
1840        let scores = crate::relevance::calculate_relevance(&self.working_files, &events, |hash| {
1841            self.file_cache.get(hash).cloned()
1842        });
1843
1844        // スコアをキャッシュ
1845        self.relevance_scores.clear();
1846        for score in &scores {
1847            self.relevance_scores
1848                .insert(score.hash.clone(), score.score);
1849        }
1850
1851        // スコアでソート(スコアが高い順、スコアがないものは後ろ)
1852        self.filtered_indices.sort_by(|&a, &b| {
1853            let score_a = self
1854                .all_events
1855                .get(a)
1856                .and_then(|e| self.relevance_scores.get(&e.short_hash))
1857                .copied()
1858                .unwrap_or(-1.0);
1859            let score_b = self
1860                .all_events
1861                .get(b)
1862                .and_then(|e| self.relevance_scores.get(&e.short_hash))
1863                .copied()
1864                .unwrap_or(-1.0);
1865            score_b
1866                .partial_cmp(&score_a)
1867                .unwrap_or(std::cmp::Ordering::Equal)
1868        });
1869
1870        self.selected_index = 0;
1871        let related_count = scores.len();
1872        self.set_status_message(format!("Relevance mode: {} related commits", related_count));
1873    }
1874
1875    /// 指定したハッシュの関連スコアを取得
1876    pub fn get_relevance_score(&self, hash: &str) -> Option<f32> {
1877        self.relevance_scores.get(hash).copied()
1878    }
1879
1880    /// 関連モードが有効かどうか
1881    pub fn is_relevance_mode(&self) -> bool {
1882        self.relevance_mode
1883    }
1884
1885    // ===== 関連ファイルビュー関連 =====
1886
1887    /// 関連ファイルビューを開始
1888    pub fn start_related_files_view(&mut self, related_files: RelatedFiles) {
1889        self.related_files_cache = Some(related_files);
1890        self.related_files_selected_index = 0;
1891        self.related_files_scroll_offset = 0;
1892        self.input_mode = InputMode::RelatedFilesView;
1893    }
1894
1895    /// 関連ファイルビューを終了
1896    pub fn close_related_files_view(&mut self) {
1897        self.related_files_cache = None;
1898        self.input_mode = InputMode::Normal;
1899        self.open_detail(); // 詳細ビューに戻る
1900    }
1901
1902    /// 関連ファイルビューで下に移動
1903    pub fn related_files_move_down(&mut self) {
1904        if let Some(ref related) = self.related_files_cache {
1905            if self.related_files_selected_index < related.related.len().saturating_sub(1) {
1906                self.related_files_selected_index += 1;
1907            }
1908        }
1909    }
1910
1911    /// 関連ファイルビューで上に移動
1912    pub fn related_files_move_up(&mut self) {
1913        if self.related_files_selected_index > 0 {
1914            self.related_files_selected_index -= 1;
1915        }
1916    }
1917
1918    /// 関連ファイルビューのスクロールを調整
1919    pub fn related_files_adjust_scroll(&mut self, visible_lines: usize) {
1920        if self.related_files_selected_index >= self.related_files_scroll_offset + visible_lines {
1921            self.related_files_scroll_offset =
1922                self.related_files_selected_index - visible_lines + 1;
1923        } else if self.related_files_selected_index < self.related_files_scroll_offset {
1924            self.related_files_scroll_offset = self.related_files_selected_index;
1925        }
1926    }
1927}
1928
1929impl Default for App {
1930    fn default() -> Self {
1931        Self::new()
1932    }
1933}
1934
1935#[cfg(test)]
1936mod tests {
1937    use super::*;
1938    use chrono::Local;
1939
1940    fn create_test_event(message: &str) -> GitEvent {
1941        GitEvent::commit(
1942            "abc1234".to_string(),
1943            message.to_string(),
1944            "author".to_string(),
1945            Local::now(),
1946            0,
1947            0,
1948        )
1949    }
1950
1951    fn create_test_repo_info() -> RepoInfo {
1952        RepoInfo {
1953            name: "test-repo".to_string(),
1954            branch: "main".to_string(),
1955        }
1956    }
1957
1958    #[test]
1959    fn test_app_new_initializes_with_should_quit_false() {
1960        let app = App::new();
1961        assert!(!app.should_quit);
1962    }
1963
1964    #[test]
1965    fn test_app_new_initializes_with_empty_events() {
1966        let app = App::new();
1967        assert!(app.events().is_empty());
1968    }
1969
1970    #[test]
1971    fn test_app_new_initializes_with_no_repo_info() {
1972        let app = App::new();
1973        assert!(app.repo_info.is_none());
1974    }
1975
1976    #[test]
1977    fn test_app_quit_sets_should_quit_to_true() {
1978        let mut app = App::new();
1979        app.quit();
1980        assert!(app.should_quit);
1981    }
1982
1983    #[test]
1984    fn test_app_default_is_same_as_new() {
1985        let app1 = App::new();
1986        let app2 = App::default();
1987        assert_eq!(app1.should_quit, app2.should_quit);
1988    }
1989
1990    #[test]
1991    fn test_app_load_sets_repo_info() {
1992        let mut app = App::new();
1993        let repo_info = create_test_repo_info();
1994        app.load(repo_info, vec![]);
1995        assert!(app.repo_info.is_some());
1996        assert_eq!(app.repo_name(), "test-repo");
1997    }
1998
1999    #[test]
2000    fn test_app_load_sets_events() {
2001        let mut app = App::new();
2002        let events = vec![create_test_event("commit 1"), create_test_event("commit 2")];
2003        app.load(create_test_repo_info(), events);
2004        assert_eq!(app.event_count(), 2);
2005    }
2006
2007    #[test]
2008    fn test_app_load_resets_selected_index() {
2009        let mut app = App::new();
2010        app.selected_index = 5;
2011        app.load(create_test_repo_info(), vec![create_test_event("commit")]);
2012        assert_eq!(app.selected_index, 0);
2013    }
2014
2015    #[test]
2016    fn test_app_move_down_increments_selected_index() {
2017        let mut app = App::new();
2018        app.load(
2019            create_test_repo_info(),
2020            vec![create_test_event("1"), create_test_event("2")],
2021        );
2022        app.move_down();
2023        assert_eq!(app.selected_index, 1);
2024    }
2025
2026    #[test]
2027    fn test_app_move_down_does_not_exceed_events_length() {
2028        let mut app = App::new();
2029        app.load(
2030            create_test_repo_info(),
2031            vec![create_test_event("1"), create_test_event("2")],
2032        );
2033        app.move_down();
2034        app.move_down();
2035        app.move_down();
2036        assert_eq!(app.selected_index, 1);
2037    }
2038
2039    #[test]
2040    fn test_app_move_up_decrements_selected_index() {
2041        let mut app = App::new();
2042        app.load(
2043            create_test_repo_info(),
2044            vec![create_test_event("1"), create_test_event("2")],
2045        );
2046        app.selected_index = 1;
2047        app.move_up();
2048        assert_eq!(app.selected_index, 0);
2049    }
2050
2051    #[test]
2052    fn test_app_move_up_does_not_go_below_zero() {
2053        let mut app = App::new();
2054        app.load(create_test_repo_info(), vec![create_test_event("1")]);
2055        app.move_up();
2056        assert_eq!(app.selected_index, 0);
2057    }
2058
2059    #[test]
2060    fn test_app_selected_event_returns_correct_event() {
2061        let mut app = App::new();
2062        app.load(
2063            create_test_repo_info(),
2064            vec![create_test_event("first"), create_test_event("second")],
2065        );
2066        app.selected_index = 1;
2067        let event = app.selected_event().unwrap();
2068        assert_eq!(event.message, "second");
2069    }
2070
2071    #[test]
2072    fn test_app_selected_event_returns_none_when_empty() {
2073        let app = App::new();
2074        assert!(app.selected_event().is_none());
2075    }
2076
2077    #[test]
2078    fn test_app_repo_name_returns_unknown_when_no_repo() {
2079        let app = App::new();
2080        assert_eq!(app.repo_name(), "unknown");
2081    }
2082
2083    #[test]
2084    fn test_app_branch_name_returns_unknown_when_no_repo() {
2085        let app = App::new();
2086        assert_eq!(app.branch_name(), "unknown");
2087    }
2088
2089    #[test]
2090    fn test_app_new_initializes_with_show_detail_false() {
2091        let app = App::new();
2092        assert!(!app.show_detail);
2093    }
2094
2095    #[test]
2096    fn test_app_open_detail_sets_show_detail_true() {
2097        let mut app = App::new();
2098        app.load(create_test_repo_info(), vec![create_test_event("commit")]);
2099        app.open_detail();
2100        assert!(app.show_detail);
2101    }
2102
2103    #[test]
2104    fn test_app_open_detail_does_nothing_when_no_events() {
2105        let mut app = App::new();
2106        app.open_detail();
2107        assert!(!app.show_detail);
2108    }
2109
2110    #[test]
2111    fn test_app_close_detail_sets_show_detail_false() {
2112        let mut app = App::new();
2113        app.load(create_test_repo_info(), vec![create_test_event("commit")]);
2114        app.open_detail();
2115        app.close_detail();
2116        assert!(!app.show_detail);
2117    }
2118
2119    #[test]
2120    fn test_app_new_initializes_with_normal_mode() {
2121        let app = App::new();
2122        assert_eq!(app.input_mode, InputMode::Normal);
2123    }
2124
2125    #[test]
2126    fn test_app_start_filter_sets_filter_mode() {
2127        let mut app = App::new();
2128        app.start_filter();
2129        assert_eq!(app.input_mode, InputMode::Filter);
2130    }
2131
2132    #[test]
2133    fn test_app_end_filter_sets_normal_mode() {
2134        let mut app = App::new();
2135        app.start_filter();
2136        app.end_filter();
2137        assert_eq!(app.input_mode, InputMode::Normal);
2138    }
2139
2140    #[test]
2141    fn test_app_filter_push_adds_character() {
2142        let mut app = App::new();
2143        app.load(create_test_repo_info(), vec![create_test_event("test")]);
2144        app.filter_push('a');
2145        assert_eq!(app.filter_text, "a");
2146    }
2147
2148    #[test]
2149    fn test_app_filter_pop_removes_character() {
2150        let mut app = App::new();
2151        app.load(create_test_repo_info(), vec![create_test_event("test")]);
2152        app.filter_push('a');
2153        app.filter_push('b');
2154        app.filter_pop();
2155        assert_eq!(app.filter_text, "a");
2156    }
2157
2158    #[test]
2159    fn test_app_filter_clear_removes_all() {
2160        let mut app = App::new();
2161        app.load(create_test_repo_info(), vec![create_test_event("test")]);
2162        app.filter_push('a');
2163        app.filter_push('b');
2164        app.filter_clear();
2165        assert_eq!(app.filter_text, "");
2166    }
2167
2168    #[test]
2169    fn test_app_filter_filters_by_message() {
2170        let mut app = App::new();
2171        app.load(
2172            create_test_repo_info(),
2173            vec![
2174                create_test_event("feat: add feature"),
2175                create_test_event("fix: bug fix"),
2176                create_test_event("feat: another feature"),
2177            ],
2178        );
2179        app.filter_push('f');
2180        app.filter_push('i');
2181        app.filter_push('x');
2182        assert_eq!(app.event_count(), 1);
2183    }
2184
2185    #[test]
2186    fn test_app_filter_is_case_insensitive() {
2187        let mut app = App::new();
2188        app.load(
2189            create_test_repo_info(),
2190            vec![create_test_event("FEAT: Add Feature")],
2191        );
2192        app.filter_push('f');
2193        app.filter_push('e');
2194        app.filter_push('a');
2195        app.filter_push('t');
2196        assert_eq!(app.event_count(), 1);
2197    }
2198
2199    #[test]
2200    fn test_app_filter_resets_selected_index() {
2201        let mut app = App::new();
2202        app.load(
2203            create_test_repo_info(),
2204            vec![
2205                create_test_event("first"),
2206                create_test_event("second"),
2207                create_test_event("third"),
2208            ],
2209        );
2210        app.selected_index = 2;
2211        app.filter_push('f');
2212        app.filter_push('i');
2213        app.filter_push('r');
2214        // "first"のみマッチ、selected_indexは0に調整
2215        assert_eq!(app.selected_index, 0);
2216    }
2217
2218    #[test]
2219    fn test_app_start_branch_select_sets_mode() {
2220        let mut app = App::new();
2221        app.load(create_test_repo_info(), vec![]);
2222        app.start_branch_select(vec!["main".to_string(), "develop".to_string()]);
2223        assert_eq!(app.input_mode, InputMode::BranchSelect);
2224    }
2225
2226    #[test]
2227    fn test_app_start_branch_select_sets_branches() {
2228        let mut app = App::new();
2229        app.load(create_test_repo_info(), vec![]);
2230        app.start_branch_select(vec!["main".to_string(), "develop".to_string()]);
2231        assert_eq!(app.branches.len(), 2);
2232    }
2233
2234    #[test]
2235    fn test_app_start_branch_select_selects_current_branch() {
2236        let mut app = App::new();
2237        app.load(create_test_repo_info(), vec![]); // main branch
2238        app.start_branch_select(vec!["develop".to_string(), "main".to_string()]);
2239        assert_eq!(app.branch_selected_index, 1);
2240    }
2241
2242    #[test]
2243    fn test_app_end_branch_select_clears_branches() {
2244        let mut app = App::new();
2245        app.load(create_test_repo_info(), vec![]);
2246        app.start_branch_select(vec!["main".to_string()]);
2247        app.end_branch_select();
2248        assert!(app.branches.is_empty());
2249        assert_eq!(app.input_mode, InputMode::Normal);
2250    }
2251
2252    #[test]
2253    fn test_app_branch_move_down_increments_index() {
2254        let mut app = App::new();
2255        app.load(create_test_repo_info(), vec![]);
2256        app.start_branch_select(vec!["a".to_string(), "b".to_string(), "c".to_string()]);
2257        app.branch_selected_index = 0;
2258        app.branch_move_down();
2259        assert_eq!(app.branch_selected_index, 1);
2260    }
2261
2262    #[test]
2263    fn test_app_branch_move_up_decrements_index() {
2264        let mut app = App::new();
2265        app.load(create_test_repo_info(), vec![]);
2266        app.start_branch_select(vec!["a".to_string(), "b".to_string(), "c".to_string()]);
2267        app.branch_selected_index = 2;
2268        app.branch_move_up();
2269        assert_eq!(app.branch_selected_index, 1);
2270    }
2271
2272    #[test]
2273    fn test_app_selected_branch_returns_correct_branch() {
2274        let mut app = App::new();
2275        app.load(create_test_repo_info(), vec![]);
2276        app.start_branch_select(vec!["a".to_string(), "b".to_string(), "c".to_string()]);
2277        app.branch_selected_index = 1;
2278        assert_eq!(app.selected_branch(), Some("b"));
2279    }
2280
2281    #[test]
2282    fn test_app_update_branch_changes_repo_info() {
2283        let mut app = App::new();
2284        app.load(create_test_repo_info(), vec![]);
2285        app.update_branch("new-branch".to_string());
2286        assert_eq!(app.branch_name(), "new-branch");
2287    }
2288
2289    fn create_test_file_status() -> FileStatus {
2290        use crate::git::FileStatusKind;
2291        FileStatus {
2292            path: "test.txt".to_string(),
2293            kind: FileStatusKind::Modified,
2294        }
2295    }
2296
2297    #[test]
2298    fn test_app_start_status_view_sets_mode() {
2299        let mut app = App::new();
2300        app.start_status_view(vec![create_test_file_status()]);
2301        assert_eq!(app.input_mode, InputMode::StatusView);
2302    }
2303
2304    #[test]
2305    fn test_app_start_status_view_sets_statuses() {
2306        let mut app = App::new();
2307        app.start_status_view(vec![create_test_file_status(), create_test_file_status()]);
2308        assert_eq!(app.file_statuses.len(), 2);
2309    }
2310
2311    #[test]
2312    fn test_app_end_status_view_clears_statuses() {
2313        let mut app = App::new();
2314        app.start_status_view(vec![create_test_file_status()]);
2315        app.end_status_view();
2316        assert!(app.file_statuses.is_empty());
2317        assert_eq!(app.input_mode, InputMode::Normal);
2318    }
2319
2320    #[test]
2321    fn test_app_status_move_down_increments_index() {
2322        let mut app = App::new();
2323        app.start_status_view(vec![create_test_file_status(), create_test_file_status()]);
2324        app.status_move_down();
2325        assert_eq!(app.status_selected_index, 1);
2326    }
2327
2328    #[test]
2329    fn test_app_status_move_up_decrements_index() {
2330        let mut app = App::new();
2331        app.start_status_view(vec![create_test_file_status(), create_test_file_status()]);
2332        app.status_selected_index = 1;
2333        app.status_move_up();
2334        assert_eq!(app.status_selected_index, 0);
2335    }
2336
2337    #[test]
2338    fn test_app_selected_file_status_returns_correct_status() {
2339        let mut app = App::new();
2340        app.start_status_view(vec![create_test_file_status()]);
2341        let status = app.selected_file_status().unwrap();
2342        assert_eq!(status.path, "test.txt");
2343    }
2344
2345    #[test]
2346    fn test_app_start_commit_input_sets_mode() {
2347        let mut app = App::new();
2348        app.start_commit_input();
2349        assert_eq!(app.input_mode, InputMode::CommitInput);
2350    }
2351
2352    #[test]
2353    fn test_app_commit_message_push_adds_character() {
2354        let mut app = App::new();
2355        app.commit_message_push('t');
2356        app.commit_message_push('e');
2357        app.commit_message_push('s');
2358        app.commit_message_push('t');
2359        assert_eq!(app.commit_message, "test");
2360    }
2361
2362    #[test]
2363    fn test_app_commit_message_pop_removes_character() {
2364        let mut app = App::new();
2365        app.commit_message_push('a');
2366        app.commit_message_push('b');
2367        app.commit_message_pop();
2368        assert_eq!(app.commit_message, "a");
2369    }
2370
2371    #[test]
2372    fn test_app_set_status_message_sets_message() {
2373        let mut app = App::new();
2374        app.set_status_message("Success".to_string());
2375        assert_eq!(app.status_message, Some("Success".to_string()));
2376    }
2377
2378    #[test]
2379    fn test_app_new_initializes_with_is_loading_false() {
2380        let app = App::new();
2381        assert!(!app.is_loading);
2382    }
2383
2384    #[test]
2385    fn test_app_start_loading_sets_is_loading_true() {
2386        let mut app = App::new();
2387        app.start_loading();
2388        assert!(app.is_loading);
2389    }
2390
2391    #[test]
2392    fn test_app_finish_loading_sets_is_loading_false() {
2393        let mut app = App::new();
2394        app.start_loading();
2395        app.finish_loading();
2396        assert!(!app.is_loading);
2397    }
2398
2399    #[test]
2400    fn test_app_append_events_adds_to_existing() {
2401        let mut app = App::new();
2402        app.load(create_test_repo_info(), vec![create_test_event("first")]);
2403        assert_eq!(app.event_count(), 1);
2404
2405        app.append_events(vec![
2406            create_test_event("second"),
2407            create_test_event("third"),
2408        ]);
2409        assert_eq!(app.event_count(), 3);
2410    }
2411
2412    #[test]
2413    fn test_app_append_events_updates_filtered_indices() {
2414        let mut app = App::new();
2415        app.load(create_test_repo_info(), vec![create_test_event("first")]);
2416        app.append_events(vec![create_test_event("second")]);
2417
2418        // フィルタなしなら全てのイベントが表示される
2419        assert_eq!(app.events().len(), 2);
2420    }
2421
2422    // ===== Vim風ナビゲーションのテスト =====
2423
2424    #[test]
2425    fn test_app_move_to_top_sets_selected_index_to_zero() {
2426        let mut app = App::new();
2427        app.load(
2428            create_test_repo_info(),
2429            vec![
2430                create_test_event("1"),
2431                create_test_event("2"),
2432                create_test_event("3"),
2433            ],
2434        );
2435        app.selected_index = 2;
2436        app.move_to_top();
2437        assert_eq!(app.selected_index, 0);
2438    }
2439
2440    #[test]
2441    fn test_app_move_to_top_works_when_already_at_top() {
2442        let mut app = App::new();
2443        app.load(create_test_repo_info(), vec![create_test_event("1")]);
2444        app.selected_index = 0;
2445        app.move_to_top();
2446        assert_eq!(app.selected_index, 0);
2447    }
2448
2449    #[test]
2450    fn test_app_move_to_bottom_sets_selected_index_to_last() {
2451        let mut app = App::new();
2452        app.load(
2453            create_test_repo_info(),
2454            vec![
2455                create_test_event("1"),
2456                create_test_event("2"),
2457                create_test_event("3"),
2458            ],
2459        );
2460        app.selected_index = 0;
2461        app.move_to_bottom();
2462        assert_eq!(app.selected_index, 2);
2463    }
2464
2465    #[test]
2466    fn test_app_move_to_bottom_does_nothing_when_empty() {
2467        let mut app = App::new();
2468        app.move_to_bottom();
2469        assert_eq!(app.selected_index, 0);
2470    }
2471
2472    #[test]
2473    fn test_app_page_down_moves_by_page_size() {
2474        let mut app = App::new();
2475        let events: Vec<_> = (0..20)
2476            .map(|i| create_test_event(&format!("{}", i)))
2477            .collect();
2478        app.load(create_test_repo_info(), events);
2479        app.selected_index = 0;
2480        app.page_down(10);
2481        assert_eq!(app.selected_index, 10);
2482    }
2483
2484    #[test]
2485    fn test_app_page_down_stops_at_last_index() {
2486        let mut app = App::new();
2487        let events: Vec<_> = (0..5)
2488            .map(|i| create_test_event(&format!("{}", i)))
2489            .collect();
2490        app.load(create_test_repo_info(), events);
2491        app.selected_index = 0;
2492        app.page_down(10);
2493        assert_eq!(app.selected_index, 4);
2494    }
2495
2496    #[test]
2497    fn test_app_page_down_does_nothing_when_empty() {
2498        let mut app = App::new();
2499        app.page_down(10);
2500        assert_eq!(app.selected_index, 0);
2501    }
2502
2503    #[test]
2504    fn test_app_page_up_moves_by_page_size() {
2505        let mut app = App::new();
2506        let events: Vec<_> = (0..20)
2507            .map(|i| create_test_event(&format!("{}", i)))
2508            .collect();
2509        app.load(create_test_repo_info(), events);
2510        app.selected_index = 15;
2511        app.page_up(10);
2512        assert_eq!(app.selected_index, 5);
2513    }
2514
2515    #[test]
2516    fn test_app_page_up_stops_at_zero() {
2517        let mut app = App::new();
2518        let events: Vec<_> = (0..5)
2519            .map(|i| create_test_event(&format!("{}", i)))
2520            .collect();
2521        app.load(create_test_repo_info(), events);
2522        app.selected_index = 3;
2523        app.page_up(10);
2524        assert_eq!(app.selected_index, 0);
2525    }
2526
2527    // ===== ヘルプ表示のテスト =====
2528
2529    #[test]
2530    fn test_app_new_initializes_with_show_help_false() {
2531        let app = App::new();
2532        assert!(!app.show_help);
2533    }
2534
2535    #[test]
2536    fn test_app_toggle_help_sets_show_help_true() {
2537        let mut app = App::new();
2538        app.toggle_help();
2539        assert!(app.show_help);
2540    }
2541
2542    #[test]
2543    fn test_app_toggle_help_toggles_show_help() {
2544        let mut app = App::new();
2545        app.toggle_help();
2546        assert!(app.show_help);
2547        app.toggle_help();
2548        assert!(!app.show_help);
2549    }
2550
2551    #[test]
2552    fn test_app_close_help_sets_show_help_false() {
2553        let mut app = App::new();
2554        app.show_help = true;
2555        app.close_help();
2556        assert!(!app.show_help);
2557    }
2558
2559    #[test]
2560    fn test_app_close_help_does_nothing_when_already_closed() {
2561        let mut app = App::new();
2562        app.close_help();
2563        assert!(!app.show_help);
2564    }
2565
2566    // ===== jump_to_head のテスト =====
2567
2568    #[test]
2569    fn test_app_jump_to_head_moves_to_head_event() {
2570        let mut app = App::new();
2571        app.load(
2572            create_test_repo_info(),
2573            vec![
2574                create_test_event("1"),
2575                create_test_event("2"),
2576                create_test_event("3"),
2577            ],
2578        );
2579        // HEADは最初のイベント(abc1234)
2580        app.set_head_hash("abc1234".to_string());
2581        app.selected_index = 2;
2582        app.jump_to_head();
2583        assert_eq!(app.selected_index, 0);
2584    }
2585
2586    #[test]
2587    fn test_app_jump_to_head_works_when_already_at_head() {
2588        let mut app = App::new();
2589        app.load(create_test_repo_info(), vec![create_test_event("1")]);
2590        app.set_head_hash("abc1234".to_string());
2591        app.selected_index = 0;
2592        app.jump_to_head();
2593        assert_eq!(app.selected_index, 0);
2594    }
2595
2596    #[test]
2597    fn test_app_jump_to_head_finds_head_in_filtered_results() {
2598        let mut app = App::new();
2599        app.load(
2600            create_test_repo_info(),
2601            vec![
2602                create_test_event("HEAD commit"),
2603                create_test_event("other"),
2604                create_test_event("HEAD related"),
2605            ],
2606        );
2607        // HEADは最初のイベント
2608        app.set_head_hash("abc1234".to_string());
2609        // "HEAD"でフィルタ → [0, 2]がマッチ
2610        app.filter_push('H');
2611        app.filter_push('E');
2612        app.filter_push('A');
2613        app.filter_push('D');
2614        // selected_indexをフィルタ結果内で最後(2番目)に設定
2615        app.selected_index = 1;
2616        // HEADにジャンプ → インデックス0はフィルタ結果の0番目
2617        app.jump_to_head();
2618        assert_eq!(app.selected_index, 0);
2619    }
2620
2621    #[test]
2622    fn test_app_jump_to_head_does_nothing_when_head_not_in_filter() {
2623        let mut app = App::new();
2624        app.load(
2625            create_test_repo_info(),
2626            vec![
2627                create_test_event("first"),
2628                create_test_event("match me"),
2629                create_test_event("another match me"),
2630            ],
2631        );
2632        // HEADは最初のイベント
2633        app.set_head_hash("abc1234".to_string());
2634        // "match"でフィルタ → [1, 2]がマッチ、HEADの0は含まれない
2635        app.filter_push('m');
2636        app.filter_push('a');
2637        app.filter_push('t');
2638        app.filter_push('c');
2639        app.filter_push('h');
2640        app.selected_index = 1;
2641        // HEADにジャンプ → インデックス0はフィルタ結果に含まれないので移動しない
2642        app.jump_to_head();
2643        assert_eq!(app.selected_index, 1);
2644    }
2645
2646    #[test]
2647    fn test_app_jump_to_head_without_head_hash_goes_to_first() {
2648        let mut app = App::new();
2649        app.load(
2650            create_test_repo_info(),
2651            vec![
2652                create_test_event("1"),
2653                create_test_event("2"),
2654                create_test_event("3"),
2655            ],
2656        );
2657        // head_hashが設定されていない場合は先頭にジャンプ
2658        app.selected_index = 2;
2659        app.jump_to_head();
2660        assert_eq!(app.selected_index, 0);
2661    }
2662
2663    #[test]
2664    fn test_app_set_head_hash_truncates_long_hash() {
2665        let mut app = App::new();
2666        app.set_head_hash("abcdef1234567890".to_string());
2667        assert_eq!(app.head_hash, Some("abcdef1".to_string()));
2668    }
2669
2670    #[test]
2671    fn test_app_set_head_hash_keeps_short_hash() {
2672        let mut app = App::new();
2673        app.set_head_hash("abc1234".to_string());
2674        assert_eq!(app.head_hash, Some("abc1234".to_string()));
2675    }
2676
2677    // ===== 表示モードのテスト =====
2678
2679    #[test]
2680    fn test_app_new_initializes_with_normal_view_mode() {
2681        let app = App::new();
2682        assert_eq!(app.view_mode, ViewMode::Normal);
2683    }
2684
2685    #[test]
2686    fn test_app_cycle_view_mode_normal_to_compact() {
2687        let mut app = App::new();
2688        app.cycle_view_mode();
2689        assert_eq!(app.view_mode, ViewMode::Compact);
2690    }
2691
2692    #[test]
2693    fn test_app_cycle_view_mode_toggle() {
2694        let mut app = App::new();
2695        assert_eq!(app.view_mode, ViewMode::Normal);
2696        app.cycle_view_mode();
2697        assert_eq!(app.view_mode, ViewMode::Compact);
2698        app.cycle_view_mode();
2699        assert_eq!(app.view_mode, ViewMode::Normal);
2700    }
2701
2702    // ===== ラベルジャンプのテスト =====
2703
2704    fn create_labeled_event(message: &str, labels: Vec<&str>) -> GitEvent {
2705        let mut event = GitEvent::commit(
2706            "abc1234".to_string(),
2707            message.to_string(),
2708            "author".to_string(),
2709            Local::now(),
2710            0,
2711            0,
2712        );
2713        event.branch_labels = labels.into_iter().map(|s| s.to_string()).collect();
2714        event
2715    }
2716
2717    #[test]
2718    fn test_app_jump_to_next_label_finds_label() {
2719        let mut app = App::new();
2720        app.load(
2721            create_test_repo_info(),
2722            vec![
2723                create_test_event("no label 1"),
2724                create_test_event("no label 2"),
2725                create_labeled_event("with label", vec!["main"]),
2726                create_test_event("no label 3"),
2727            ],
2728        );
2729        app.selected_index = 0;
2730        app.jump_to_next_label();
2731        assert_eq!(app.selected_index, 2);
2732    }
2733
2734    #[test]
2735    fn test_app_jump_to_next_label_no_label_found() {
2736        let mut app = App::new();
2737        app.load(
2738            create_test_repo_info(),
2739            vec![
2740                create_test_event("no label 1"),
2741                create_test_event("no label 2"),
2742                create_test_event("no label 3"),
2743            ],
2744        );
2745        app.selected_index = 0;
2746        app.jump_to_next_label();
2747        assert_eq!(app.selected_index, 0); // 変わらない
2748    }
2749
2750    #[test]
2751    fn test_app_jump_to_prev_label_finds_label() {
2752        let mut app = App::new();
2753        app.load(
2754            create_test_repo_info(),
2755            vec![
2756                create_labeled_event("with label", vec!["main"]),
2757                create_test_event("no label 1"),
2758                create_test_event("no label 2"),
2759                create_test_event("no label 3"),
2760            ],
2761        );
2762        app.selected_index = 3;
2763        app.jump_to_prev_label();
2764        assert_eq!(app.selected_index, 0);
2765    }
2766
2767    #[test]
2768    fn test_app_jump_to_prev_label_no_label_found() {
2769        let mut app = App::new();
2770        app.load(
2771            create_test_repo_info(),
2772            vec![
2773                create_test_event("no label 1"),
2774                create_test_event("no label 2"),
2775                create_test_event("no label 3"),
2776            ],
2777        );
2778        app.selected_index = 2;
2779        app.jump_to_prev_label();
2780        assert_eq!(app.selected_index, 2); // 変わらない
2781    }
2782
2783    #[test]
2784    fn test_app_jump_to_prev_label_at_start() {
2785        let mut app = App::new();
2786        app.load(
2787            create_test_repo_info(),
2788            vec![
2789                create_labeled_event("with label", vec!["main"]),
2790                create_test_event("no label"),
2791            ],
2792        );
2793        app.selected_index = 0;
2794        app.jump_to_prev_label();
2795        assert_eq!(app.selected_index, 0); // 変わらない(開始位置)
2796    }
2797
2798    // ===== CommitType のテスト =====
2799
2800    #[test]
2801    fn test_commit_type_prefix_feat() {
2802        assert_eq!(CommitType::Feat.prefix(), "feat: ");
2803    }
2804
2805    #[test]
2806    fn test_commit_type_prefix_fix() {
2807        assert_eq!(CommitType::Fix.prefix(), "fix: ");
2808    }
2809
2810    #[test]
2811    fn test_commit_type_prefix_all_types() {
2812        assert_eq!(CommitType::Docs.prefix(), "docs: ");
2813        assert_eq!(CommitType::Style.prefix(), "style: ");
2814        assert_eq!(CommitType::Refactor.prefix(), "refactor: ");
2815        assert_eq!(CommitType::Test.prefix(), "test: ");
2816        assert_eq!(CommitType::Chore.prefix(), "chore: ");
2817        assert_eq!(CommitType::Perf.prefix(), "perf: ");
2818    }
2819
2820    #[test]
2821    fn test_commit_type_key_feat() {
2822        assert_eq!(CommitType::Feat.key(), 'f');
2823    }
2824
2825    #[test]
2826    fn test_commit_type_key_fix() {
2827        assert_eq!(CommitType::Fix.key(), 'x');
2828    }
2829
2830    #[test]
2831    fn test_commit_type_key_all_types() {
2832        assert_eq!(CommitType::Docs.key(), 'd');
2833        assert_eq!(CommitType::Style.key(), 's');
2834        assert_eq!(CommitType::Refactor.key(), 'r');
2835        assert_eq!(CommitType::Test.key(), 't');
2836        assert_eq!(CommitType::Chore.key(), 'c');
2837        assert_eq!(CommitType::Perf.key(), 'p');
2838    }
2839
2840    #[test]
2841    fn test_commit_type_all_returns_8_types() {
2842        assert_eq!(CommitType::all().len(), 8);
2843    }
2844
2845    #[test]
2846    fn test_select_commit_type_sets_prefix() {
2847        let mut app = App::new();
2848        app.select_commit_type(CommitType::Feat);
2849        assert_eq!(app.commit_message, "feat: ");
2850        assert_eq!(app.commit_type, Some(CommitType::Feat));
2851    }
2852
2853    #[test]
2854    fn test_select_commit_type_fix() {
2855        let mut app = App::new();
2856        app.select_commit_type(CommitType::Fix);
2857        assert_eq!(app.commit_message, "fix: ");
2858        assert_eq!(app.commit_type, Some(CommitType::Fix));
2859    }
2860
2861    #[test]
2862    fn test_commit_message_pop_clears_type_when_prefix_removed() {
2863        let mut app = App::new();
2864        app.select_commit_type(CommitType::Feat);
2865        // "feat: " を消していく
2866        for _ in 0..6 {
2867            app.commit_message_pop();
2868        }
2869        assert_eq!(app.commit_type, None);
2870    }
2871
2872    #[test]
2873    fn test_commit_message_clear_clears_type() {
2874        let mut app = App::new();
2875        app.select_commit_type(CommitType::Feat);
2876        app.commit_message_clear();
2877        assert_eq!(app.commit_type, None);
2878        assert_eq!(app.commit_message, "");
2879    }
2880
2881    #[test]
2882    fn test_start_commit_input_clears_type() {
2883        let mut app = App::new();
2884        app.commit_type = Some(CommitType::Feat);
2885        app.start_commit_input();
2886        assert_eq!(app.commit_type, None);
2887    }
2888
2889    #[test]
2890    fn test_end_commit_input_clears_type() {
2891        let mut app = App::new();
2892        app.start_commit_input();
2893        app.select_commit_type(CommitType::Feat);
2894        app.end_commit_input();
2895        assert_eq!(app.commit_type, None);
2896    }
2897
2898    // ===== 提案関連のテスト =====
2899
2900    #[test]
2901    fn test_app_new_initializes_with_empty_suggestions() {
2902        let app = App::new();
2903        assert!(app.commit_suggestions.is_empty());
2904        assert_eq!(app.suggestion_selected_index, 0);
2905    }
2906
2907    #[test]
2908    fn test_suggestion_move_down_increments_index() {
2909        let mut app = App::new();
2910        app.commit_suggestions = vec![
2911            crate::suggestion::CommitSuggestion {
2912                commit_type: CommitType::Feat,
2913                scope: None,
2914                message: "test1".to_string(),
2915                confidence: 0.8,
2916            },
2917            crate::suggestion::CommitSuggestion {
2918                commit_type: CommitType::Fix,
2919                scope: None,
2920                message: "test2".to_string(),
2921                confidence: 0.6,
2922            },
2923        ];
2924        app.suggestion_move_down();
2925        assert_eq!(app.suggestion_selected_index, 1);
2926    }
2927
2928    #[test]
2929    fn test_suggestion_move_up_decrements_index() {
2930        let mut app = App::new();
2931        app.commit_suggestions = vec![
2932            crate::suggestion::CommitSuggestion {
2933                commit_type: CommitType::Feat,
2934                scope: None,
2935                message: "test1".to_string(),
2936                confidence: 0.8,
2937            },
2938            crate::suggestion::CommitSuggestion {
2939                commit_type: CommitType::Fix,
2940                scope: None,
2941                message: "test2".to_string(),
2942                confidence: 0.6,
2943            },
2944        ];
2945        app.suggestion_selected_index = 1;
2946        app.suggestion_move_up();
2947        assert_eq!(app.suggestion_selected_index, 0);
2948    }
2949
2950    #[test]
2951    fn test_apply_suggestion_sets_message_and_type() {
2952        let mut app = App::new();
2953        app.commit_suggestions = vec![crate::suggestion::CommitSuggestion {
2954            commit_type: CommitType::Feat,
2955            scope: Some("auth".to_string()),
2956            message: "add login".to_string(),
2957            confidence: 0.8,
2958        }];
2959        app.apply_suggestion(0);
2960        assert_eq!(app.commit_type, Some(CommitType::Feat));
2961        assert_eq!(app.commit_message, "feat(auth): add login");
2962    }
2963
2964    #[test]
2965    fn test_apply_suggestion_invalid_index_does_nothing() {
2966        let mut app = App::new();
2967        app.apply_suggestion(0);
2968        assert_eq!(app.commit_type, None);
2969        assert!(app.commit_message.is_empty());
2970    }
2971
2972    // ===== LayoutMode関連のテスト =====
2973
2974    #[test]
2975    fn test_layout_mode_default_is_single() {
2976        let app = App::new();
2977        assert_eq!(app.layout_mode, LayoutMode::Single);
2978    }
2979
2980    #[test]
2981    fn test_toggle_layout_mode_single_to_split() {
2982        let mut app = App::new();
2983        assert_eq!(app.layout_mode, LayoutMode::Single);
2984        app.toggle_layout_mode();
2985        assert_eq!(app.layout_mode, LayoutMode::SplitPanel);
2986    }
2987
2988    #[test]
2989    fn test_toggle_layout_mode_split_to_single() {
2990        let mut app = App::new();
2991        app.layout_mode = LayoutMode::SplitPanel;
2992        app.toggle_layout_mode();
2993        assert_eq!(app.layout_mode, LayoutMode::Single);
2994    }
2995
2996    #[test]
2997    fn test_effective_layout_mode_single_when_narrow() {
2998        let mut app = App::new();
2999        app.layout_mode = LayoutMode::SplitPanel;
3000        // 幅119以下はSingleになる
3001        assert_eq!(app.effective_layout_mode(119), LayoutMode::Single);
3002        assert_eq!(app.effective_layout_mode(100), LayoutMode::Single);
3003        assert_eq!(app.effective_layout_mode(80), LayoutMode::Single);
3004    }
3005
3006    #[test]
3007    fn test_effective_layout_mode_split_when_wide() {
3008        let mut app = App::new();
3009        app.layout_mode = LayoutMode::SplitPanel;
3010        // 幅120以上はSplitPanelになる
3011        assert_eq!(app.effective_layout_mode(120), LayoutMode::SplitPanel);
3012        assert_eq!(app.effective_layout_mode(150), LayoutMode::SplitPanel);
3013        assert_eq!(app.effective_layout_mode(200), LayoutMode::SplitPanel);
3014    }
3015
3016    #[test]
3017    fn test_effective_layout_mode_single_when_mode_is_single() {
3018        let app = App::new();
3019        // モードがSingleなら幅に関係なくSingle
3020        assert_eq!(app.effective_layout_mode(200), LayoutMode::Single);
3021    }
3022
3023    #[test]
3024    fn test_left_panel_ratio_default() {
3025        let app = App::new();
3026        assert_eq!(app.left_panel_ratio, 65);
3027    }
3028
3029    #[test]
3030    fn test_adjust_panel_ratio_increase() {
3031        let mut app = App::new();
3032        app.adjust_panel_ratio(5);
3033        assert_eq!(app.left_panel_ratio, 70);
3034    }
3035
3036    #[test]
3037    fn test_adjust_panel_ratio_decrease() {
3038        let mut app = App::new();
3039        app.adjust_panel_ratio(-5);
3040        assert_eq!(app.left_panel_ratio, 60);
3041    }
3042
3043    #[test]
3044    fn test_adjust_panel_ratio_max_clamp() {
3045        let mut app = App::new();
3046        app.left_panel_ratio = 80;
3047        app.adjust_panel_ratio(10); // 80 + 10 = 90 -> 85に制限
3048        assert_eq!(app.left_panel_ratio, 85);
3049    }
3050
3051    #[test]
3052    fn test_adjust_panel_ratio_min_clamp() {
3053        let mut app = App::new();
3054        app.left_panel_ratio = 45;
3055        app.adjust_panel_ratio(-10); // 45 - 10 = 35 -> 40に制限
3056        assert_eq!(app.left_panel_ratio, 40);
3057    }
3058
3059    #[test]
3060    fn test_update_detail_for_split_panel_clears_cache() {
3061        let mut app = App::new();
3062        app.detail_scroll_offset = 5;
3063        app.detail_selected_file = 3;
3064        app.update_detail_for_split_panel();
3065        assert_eq!(app.detail_scroll_offset, 0);
3066        assert_eq!(app.detail_selected_file, 0);
3067        assert!(app.detail_diff_cache.is_none());
3068    }
3069}