Skip to main content

gitkraft_gui/
state.rs

1use std::collections::HashSet;
2use std::path::PathBuf;
3
4use gitkraft_core::*;
5use iced::{Color, Point, Task};
6
7use crate::message::Message;
8use crate::theme::ThemeColors;
9
10// ── Pane resize ───────────────────────────────────────────────────────────────
11
12/// Which vertical divider the user is currently dragging (if any).
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum DragTarget {
15    /// The divider between the sidebar and the commit-log panel.
16    SidebarRight,
17    /// The divider between the commit-log panel and the diff panel.
18    CommitLogRight,
19    /// The divider between the diff-viewer file list and the diff content
20    /// (only visible when a multi-file commit is selected).
21    DiffFileListRight,
22    /// The divider between the unstaged and staged panels in the staging area.
23    StagingUnstagedRight,
24    /// The divider between the staged and commit panels in the staging area.
25    StagingStagedRight,
26}
27
28/// Which horizontal divider the user is currently dragging (if any).
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum DragTargetH {
31    /// The divider between the middle row and the staging area.
32    StagingTop,
33    /// The divider between branches and stashes in the sidebar.
34    SidebarBranchesBottom,
35    /// The divider between stashes and remotes in the sidebar.
36    SidebarStashBottom,
37}
38
39/// What item was right-clicked to open the context menu.
40#[derive(Debug, Clone)]
41pub enum ContextMenu {
42    /// A local branch.
43    Branch {
44        name: String,
45        is_current: bool,
46        /// Index in the filtered local-branch list, used to approximate
47        /// the menu's on-screen position.
48        local_index: usize,
49    },
50    /// A remote-tracking branch (e.g. origin/feature-x).
51    RemoteBranch { name: String },
52    /// A commit in the log.
53    Commit { index: usize, oid: String },
54    /// A stash entry.
55    Stash { index: usize },
56    /// An unstaged file in the staging area.
57    UnstagedFile { path: String },
58    /// A staged file in the staging area.
59    StagedFile { path: String },
60    /// A file in a commit diff.
61    CommitFile { oid: String, file_path: String },
62}
63
64// ── Per-repository tab state ──────────────────────────────────────────────────
65
66/// Per-repository state — one instance per open tab.
67pub struct RepoTab {
68    // ── Repository ────────────────────────────────────────────────────────
69    /// Path to the currently opened repository (workdir root).
70    pub repo_path: Option<PathBuf>,
71    /// High-level information about the opened repository.
72    pub repo_info: Option<RepoInfo>,
73
74    // ── Branches ──────────────────────────────────────────────────────────
75    /// All branches (local + remote) in the repository.
76    pub branches: Vec<BranchInfo>,
77    /// Name of the currently checked-out branch.
78    pub current_branch: Option<String>,
79
80    // ── Commits ───────────────────────────────────────────────────────────
81    /// Commit log (newest first).
82    pub commits: Vec<CommitInfo>,
83    /// Index into `commits` of the currently selected commit.
84    pub selected_commit: Option<usize>,
85    /// Anchor commit index for range selection — set on a plain click.
86    pub anchor_commit_index: Option<usize>,
87    /// Ordered (ascending) list of commit indices in the current range selection.
88    pub selected_commits: Vec<usize>,
89    /// Per-commit graph layout rows for branch visualisation.
90    pub graph_rows: Vec<gitkraft_core::GraphRow>,
91
92    // ── Diff / Staging ────────────────────────────────────────────────────
93    /// Unstaged (working-directory) changes.
94    pub unstaged_changes: Vec<DiffInfo>,
95    /// Staged (index) changes.
96    pub staged_changes: Vec<DiffInfo>,
97    /// Lightweight file list for the currently selected commit (path + status only).
98    pub commit_files: Vec<gitkraft_core::DiffFileEntry>,
99    /// OID of the currently selected commit (needed for on-demand file diff loading).
100    pub selected_commit_oid: Option<String>,
101    /// Index of the selected file in `commit_files`.
102    pub selected_file_index: Option<usize>,
103    /// True while a single-file diff is being loaded.
104    pub is_loading_file_diff: bool,
105    /// Anchor index for range selection — set on a regular click, kept fixed while
106    /// the user extends the selection with Shift+Click.
107    pub anchor_file_index: Option<usize>,
108    /// Ordered list of file indices currently multi-selected in the commit file list (Shift+Click).
109    /// Always stored in ascending index order (lowest index first).
110    pub selected_commit_file_indices: Vec<usize>,
111    /// Diffs for all multi-selected files (populated when 2+ files are selected).
112    pub multi_file_diffs: Vec<gitkraft_core::DiffInfo>,
113    /// Combined diff for all commits in the current range selection (populated when
114    /// `selected_commits.len() > 1`). Shown in the diff panel instead of single-commit diff.
115    pub commit_range_diffs: Vec<gitkraft_core::DiffInfo>,
116    /// The diff currently displayed in the diff viewer panel.
117    pub selected_diff: Option<DiffInfo>,
118    /// Text in the commit-message input.
119    pub commit_message: String,
120
121    // ── Stash ─────────────────────────────────────────────────────────────
122    /// All stash entries.
123    pub stashes: Vec<StashEntry>,
124
125    // ── Remotes ───────────────────────────────────────────────────────────
126    /// Configured remotes.
127    pub remotes: Vec<RemoteInfo>,
128
129    // ── Per-tab UI state ──────────────────────────────────────────────────
130    /// Whether the commit detail pane is visible.
131    pub show_commit_detail: bool,
132    /// Text in the "new branch name" input.
133    pub new_branch_name: String,
134    /// Whether the inline branch-creation UI is visible.
135    pub show_branch_create: bool,
136    /// Whether the Local branches section is expanded.
137    pub local_branches_expanded: bool,
138    /// Whether the Remote branches section is expanded.
139    pub remote_branches_expanded: bool,
140    /// Text in the "stash message" input.
141    pub stash_message: String,
142
143    /// Set of selected unstaged file paths (for multi-select).
144    pub selected_unstaged: std::collections::HashSet<String>,
145    /// Set of selected staged file paths (for multi-select).
146    pub selected_staged: std::collections::HashSet<String>,
147    /// Anchor index for Shift+Click range selection in the unstaged list.
148    pub anchor_unstaged_index: Option<usize>,
149    /// Anchor index for Shift+Click range selection in the staged list.
150    pub anchor_staged_index: Option<usize>,
151
152    /// File path pending discard confirmation (None = no pending discard).
153    pub pending_discard: Option<String>,
154
155    // ── Feedback ──────────────────────────────────────────────────────────
156    /// Transient status-bar message (e.g. "Branch created").
157    pub status_message: Option<String>,
158    /// Error message shown in a banner / toast.
159    pub error_message: Option<String>,
160    /// True while an async operation is in flight.
161    pub is_loading: bool,
162    /// Cursor position captured at the moment the context menu was opened.
163    /// Used to anchor the menu so it doesn't follow the mouse after appearing.
164    pub context_menu_pos: (f32, f32),
165
166    /// Currently open context menu, if any.
167    pub context_menu: Option<ContextMenu>,
168    /// Name of the branch currently being renamed (None = not renaming).
169    pub rename_branch_target: Option<String>,
170    /// The new name being typed in the rename input.
171    pub rename_branch_input: String,
172
173    /// When `Some(oid)`, the tag-creation inline form is visible, targeting that OID.
174    pub create_tag_target_oid: Option<String>,
175    /// True when creating an annotated tag; false for a lightweight tag.
176    pub create_tag_annotated: bool,
177    /// The tag name the user is typing.
178    pub create_tag_name: String,
179    /// The annotated tag message the user is typing (only used when `create_tag_annotated` is true).
180    pub create_tag_message: String,
181    /// When `Some(oid)`, the inline "create branch at this commit" form is visible.
182    pub create_branch_at_oid: Option<String>,
183
184    /// Current scroll offset of the commit log in pixels.
185    /// Tracked via `on_scroll` so virtual scrolling can render only the
186    /// visible window of rows.
187    pub commit_scroll_offset: f32,
188
189    /// Current scroll offset of the diff viewer in pixels.
190    pub diff_scroll_offset: f32,
191    /// Pre-computed display strings for each commit:
192    /// `(truncated_summary, relative_time, truncated_author)`.
193    /// Computed once when commits load to avoid per-frame string allocations.
194    pub commit_display: Vec<(String, String, String)>,
195
196    /// Whether there are potentially more commits to load beyond those already shown.
197    pub has_more_commits: bool,
198    /// Guard: true while a background load-more task is in flight (prevents duplicates).
199    pub is_loading_more_commits: bool,
200
201    /// When `Some(path)`, the file-history overlay is shown for that repo-relative path.
202    pub file_history_path: Option<String>,
203    /// Commits loaded for the file-history overlay (newest first).
204    pub file_history_commits: Vec<gitkraft_core::CommitInfo>,
205    /// Scroll offset of the file-history list in pixels.
206    pub file_history_scroll: f32,
207
208    /// When `Some(path)`, the blame overlay is shown for that repo-relative path.
209    pub blame_path: Option<String>,
210    /// Blame lines loaded for the blame overlay.
211    pub blame_lines: Vec<gitkraft_core::BlameLine>,
212    /// Scroll offset of the blame view in pixels.
213    pub blame_scroll: f32,
214
215    /// When `Some(path)`, a delete-confirmation banner is shown for that file.
216    pub pending_delete_file: Option<String>,
217}
218
219impl RepoTab {
220    /// Create an empty tab (no repo open — shows welcome screen).
221    pub fn new_empty() -> Self {
222        Self {
223            repo_path: None,
224            repo_info: None,
225            branches: Vec::new(),
226            current_branch: None,
227            commits: Vec::new(),
228            selected_commit: None,
229            anchor_commit_index: None,
230            selected_commits: Vec::new(),
231            graph_rows: Vec::new(),
232            unstaged_changes: Vec::new(),
233            staged_changes: Vec::new(),
234            commit_files: Vec::new(),
235            selected_commit_oid: None,
236            selected_file_index: None,
237            is_loading_file_diff: false,
238            anchor_file_index: None,
239            selected_commit_file_indices: Vec::new(),
240            multi_file_diffs: Vec::new(),
241            commit_range_diffs: Vec::new(),
242            selected_diff: None,
243            commit_message: String::new(),
244            stashes: Vec::new(),
245            remotes: Vec::new(),
246            show_commit_detail: false,
247            new_branch_name: String::new(),
248            show_branch_create: false,
249            local_branches_expanded: true,
250            remote_branches_expanded: true,
251            stash_message: String::new(),
252            selected_unstaged: std::collections::HashSet::new(),
253            selected_staged: std::collections::HashSet::new(),
254            anchor_unstaged_index: None,
255            anchor_staged_index: None,
256            pending_discard: None,
257            status_message: None,
258            error_message: None,
259            is_loading: false,
260            context_menu: None,
261            context_menu_pos: (0.0, 0.0),
262            rename_branch_target: None,
263            rename_branch_input: String::new(),
264            create_tag_target_oid: None,
265            create_tag_annotated: false,
266            create_tag_name: String::new(),
267            create_tag_message: String::new(),
268            create_branch_at_oid: None,
269            commit_scroll_offset: 0.0,
270            diff_scroll_offset: 0.0,
271            commit_display: Vec::new(),
272            has_more_commits: true,
273            is_loading_more_commits: false,
274            file_history_path: None,
275            file_history_commits: Vec::new(),
276            file_history_scroll: 0.0,
277            blame_path: None,
278            blame_lines: Vec::new(),
279            blame_scroll: 0.0,
280            pending_delete_file: None,
281        }
282    }
283
284    /// Whether a repository is currently open in this tab.
285    pub fn has_repo(&self) -> bool {
286        self.repo_path.is_some()
287    }
288
289    /// Display name for the tab (last path component, or "New Tab").
290    pub fn display_name(&self) -> String {
291        gitkraft_core::repo_display_name(self.repo_path.as_deref())
292    }
293
294    /// Apply a full repo payload to this tab, resetting transient UI state.
295    ///
296    /// The currently selected commit (if any) is **re-pinned** by OID after the
297    /// new commit list arrives, so background auto-refreshes (git-watcher or
298    /// staging changes) never clear the user's selection.
299    pub fn apply_payload(
300        &mut self,
301        payload: crate::message::RepoPayload,
302        path: std::path::PathBuf,
303    ) {
304        // ── Save selection so we can restore it after the data refresh ────
305        let prev_oid = self.selected_commit_oid.clone();
306
307        // Save multi-selection OIDs so we can re-map them after the commit
308        // list is replaced.  Without this, background auto-refreshes (git
309        // watcher, fallback poll) would silently clear a Shift+click range.
310        let prev_anchor_oid = self
311            .anchor_commit_index
312            .and_then(|i| self.commits.get(i).map(|c| c.oid.clone()));
313        let prev_selected_oids: Vec<String> = self
314            .selected_commits
315            .iter()
316            .filter_map(|&i| self.commits.get(i).map(|c| c.oid.clone()))
317            .collect();
318
319        // Save file-level selection state.  File indices reference into
320        // `commit_files` which is NOT replaced during a refresh (the commit
321        // content hasn't changed), so these can be restored as-is.
322        let prev_anchor_file = self.anchor_file_index;
323        let prev_file_indices = self.selected_commit_file_indices.clone();
324        let prev_multi_file_diffs = std::mem::take(&mut self.multi_file_diffs);
325        let prev_commit_range_diffs = std::mem::take(&mut self.commit_range_diffs);
326
327        // Save staging selection — these are path-based so we can re-validate
328        // them against the refreshed unstaged/staged lists.
329        let prev_selected_unstaged = std::mem::take(&mut self.selected_unstaged);
330        let prev_selected_staged = std::mem::take(&mut self.selected_staged);
331        let prev_anchor_unstaged = self.anchor_unstaged_index;
332        let prev_anchor_staged = self.anchor_staged_index;
333
334        // Save standalone diff (set from staging click or file preview).
335        // When no commit is selected, this is the only diff the user sees.
336        let prev_selected_diff = self.selected_diff.take();
337        let prev_diff_scroll = self.diff_scroll_offset;
338
339        self.current_branch = payload.info.head_branch.clone();
340        self.repo_path = Some(path);
341        self.repo_info = Some(payload.info);
342        self.branches = payload.branches;
343        self.commits = payload.commits;
344        self.graph_rows = payload.graph_rows;
345        self.unstaged_changes = payload.unstaged;
346        self.staged_changes = payload.staged;
347        self.stashes = payload.stashes;
348        self.remotes = payload.remotes;
349
350        // Reset transient UI state.
351        // NOTE: selected_commit / commit_files / selected_diff are restored
352        // below so they survive background auto-refreshes.
353        self.selected_commit = None;
354        self.anchor_commit_index = None;
355        self.selected_commits.clear();
356        self.selected_commit_oid = None;
357        self.commit_message.clear();
358        self.error_message = None;
359        self.status_message = Some("Repository loaded.".into());
360        self.commit_scroll_offset = 0.0;
361        self.has_more_commits = true;
362        self.is_loading_more_commits = false;
363        self.anchor_unstaged_index = None;
364        self.anchor_staged_index = None;
365        self.anchor_file_index = None;
366        self.selected_commit_file_indices.clear();
367
368        // ── Restore the previously selected commit by OID ─────────────────
369        // If the commit still exists in the refreshed list, re-select it so
370        // the diff panel, file list, and selection highlight are all
371        // preserved.  This means auto-refreshes (every 5 s fallback, git
372        // watcher) never interrupt the user’s view.
373        if let Some(oid) = prev_oid {
374            if let Some(new_idx) = self.commits.iter().position(|c| c.oid == oid) {
375                self.selected_commit = Some(new_idx);
376                self.selected_commit_oid = Some(oid);
377                // commit_files, selected_file_index, is_loading_file_diff,
378                // diff_scroll_offset are intentionally left unchanged —
379                // the commit content hasn’t changed.
380
381                // Restore selected_diff (may have been set from commit view,
382                // staging click, or file preview).
383                self.selected_diff = prev_selected_diff;
384                self.diff_scroll_offset = prev_diff_scroll;
385
386                // Restore file-level selection — the commit (and its file
387                // list) survived, so these indices are still valid.
388                self.anchor_file_index = prev_anchor_file;
389                self.selected_commit_file_indices = prev_file_indices;
390                self.multi_file_diffs = prev_multi_file_diffs;
391                self.commit_range_diffs = prev_commit_range_diffs;
392            } else {
393                // Commit was rebased / force-pushed away — clear everything.
394                self.clear_diff_state();
395            }
396        } else {
397            // No commit was selected. Preserve any standalone diff set from
398            // a staging click or file preview so it survives background refreshes.
399            if prev_selected_diff.is_some() {
400                self.selected_diff = prev_selected_diff;
401                self.diff_scroll_offset = prev_diff_scroll;
402            } else {
403                self.clear_diff_state();
404            }
405        }
406
407        // ── Restore multi-selection by OID ─────────────────────────────
408        // Re-map the saved anchor and range selection from OIDs back to
409        // indices in the (possibly reordered) new commit list.
410        if let Some(anchor_oid) = prev_anchor_oid {
411            if let Some(new_anchor) = self.commits.iter().position(|c| c.oid == anchor_oid) {
412                self.anchor_commit_index = Some(new_anchor);
413            }
414        }
415        if !prev_selected_oids.is_empty() {
416            let restored: Vec<usize> = prev_selected_oids
417                .iter()
418                .filter_map(|oid| self.commits.iter().position(|c| &c.oid == oid))
419                .collect();
420            if !restored.is_empty() {
421                self.selected_commits = restored;
422                self.selected_commits.sort_unstable();
423            }
424        }
425
426        // ── Restore staging selection ───────────────────────────────────
427        // Keep only paths that still appear in the refreshed file lists.
428        self.selected_unstaged = prev_selected_unstaged
429            .into_iter()
430            .filter(|p| self.unstaged_changes.iter().any(|d| d.display_path() == p))
431            .collect();
432        self.selected_staged = prev_selected_staged
433            .into_iter()
434            .filter(|p| self.staged_changes.iter().any(|d| d.display_path() == p))
435            .collect();
436        // Restore anchors only if they still point to a valid index.
437        self.anchor_unstaged_index =
438            prev_anchor_unstaged.filter(|&i| i < self.unstaged_changes.len());
439        self.anchor_staged_index = prev_anchor_staged.filter(|&i| i < self.staged_changes.len());
440    }
441
442    /// Clear all diff-related state (used when the selected commit disappears
443    /// or no commit was selected before a refresh).
444    fn clear_diff_state(&mut self) {
445        self.selected_diff = None;
446        self.commit_files.clear();
447        self.selected_file_index = None;
448        self.is_loading_file_diff = false;
449        self.diff_scroll_offset = 0.0;
450    }
451}
452
453// ── Top-level application state ───────────────────────────────────────────────
454
455/// Top-level application state for the GitKraft GUI.
456pub struct GitKraft {
457    // ── Tabs ──────────────────────────────────────────────────────────────
458    /// All open repository tabs.
459    pub tabs: Vec<RepoTab>,
460    /// Index of the currently active/visible tab.
461    pub active_tab: usize,
462
463    // ── UI state (global, not per-tab) ────────────────────────────────────
464    /// Whether the left sidebar is expanded.
465    pub sidebar_expanded: bool,
466
467    // ── Pane widths / heights (pixels) ────────────────────────────────────
468    /// Width of the left sidebar in pixels.
469    pub sidebar_width: f32,
470    /// Width of the commit-log panel in pixels.
471    pub commit_log_width: f32,
472    /// Height of the staging area in pixels.
473    pub staging_height: f32,
474    /// Width of the diff file-list sidebar in pixels.
475    pub diff_file_list_width: f32,
476    /// Fraction of sidebar height allocated to the branches section (0.0–1.0).
477    pub sidebar_branches_ratio: f32,
478    /// Fraction of sidebar height allocated to the stashes section (0.0–1.0).
479    /// Remotes get the remainder: 1.0 - branches_ratio - stash_ratio.
480    pub sidebar_stash_ratio: f32,
481    /// Fraction of staging area width allocated to the unstaged column (0.0–1.0).
482    pub staging_unstaged_ratio: f32,
483    /// Fraction of staging area width allocated to the staged column (0.0–1.0).
484    /// Commit column gets: 1.0 - unstaged_ratio - staged_ratio.
485    pub staging_staged_ratio: f32,
486
487    /// UI scale factor (1.0 = default). Adjusted with Ctrl+/Ctrl- keyboard shortcuts.
488    pub ui_scale: f32,
489
490    // ── Drag state ────────────────────────────────────────────────────────
491    /// Which vertical divider is being dragged (if any).
492    pub dragging: Option<DragTarget>,
493    /// Which horizontal divider is being dragged (if any).
494    pub dragging_h: Option<DragTargetH>,
495    /// Last known mouse X position during a drag (absolute window coords).
496    pub drag_start_x: f32,
497    /// Last known mouse Y position during a drag (absolute window coords).
498    pub drag_start_y: f32,
499    /// Whether the first move event has been received for the current vertical drag.
500    /// `false` right after `PaneDragStart` — the first `PaneDragMove` sets the
501    /// real start position instead of computing a bogus delta from 0.0.
502    pub drag_initialized: bool,
503    /// Same as `drag_initialized` but for horizontal drags.
504    pub drag_initialized_h: bool,
505
506    // ── Cursor ────────────────────────────────────────────────────────────
507    /// Last known cursor position in window coordinates.
508    /// Updated on every mouse-move event so context menus open at the
509    /// exact spot the user right-clicked.
510    pub cursor_pos: Point,
511
512    // ── Theme ─────────────────────────────────────────────────────────────
513    /// Index into `gitkraft_core::THEME_NAMES` for the currently active theme.
514    pub current_theme_index: usize,
515
516    // ── Persistence ───────────────────────────────────────────────────────
517    /// Recently opened repositories (loaded from settings on startup).
518    pub recent_repos: Vec<gitkraft_core::RepoHistoryEntry>,
519
520    // ── Search ────────────────────────────────────────────────────────────
521    /// Whether the search overlay is visible.
522    pub search_visible: bool,
523    /// Current search query text.
524    pub search_query: String,
525    /// Search results (commit infos matching the query).
526    pub search_results: Vec<gitkraft_core::CommitInfo>,
527    /// Index of the selected search result.
528    pub search_selected: Option<usize>,
529
530    /// Files changed between the selected search commit and working tree.
531    pub search_diff_files: Vec<gitkraft_core::DiffFileEntry>,
532    /// Selected file indices in the search diff file list.
533    pub search_diff_selected: HashSet<usize>,
534    /// The diff content for the currently viewed search diff file(s).
535    pub search_diff_content: Vec<gitkraft_core::DiffInfo>,
536    /// OID of the commit being diffed against working tree in search.
537    pub search_diff_oid: Option<String>,
538
539    /// Configured editor for "Open in editor" actions.
540    pub editor: gitkraft_core::Editor,
541
542    /// Current keyboard modifier state (updated via subscription).
543    pub keyboard_modifiers: iced::keyboard::Modifiers,
544
545    /// Monotonically-increasing counter incremented on every `AnimationTick`.
546    /// Drives the loading-spinner frame selection in all UI widgets.
547    pub animation_tick: u64,
548
549    // ── Window geometry ───────────────────────────────────────────────────
550    /// Last known window width (updated on WindowResized).
551    pub window_width: f32,
552    /// Last known window height (updated on WindowResized).
553    pub window_height: f32,
554    /// Last known window X position (updated on WindowMoved).
555    pub window_x: f32,
556    /// Last known window Y position (updated on WindowMoved).
557    pub window_y: f32,
558}
559
560impl Default for GitKraft {
561    fn default() -> Self {
562        Self::new()
563    }
564}
565
566impl GitKraft {
567    /// Build application state from persisted [`AppSettings`].
568    ///
569    /// Starts with a single empty tab regardless of what was saved — callers
570    /// that want to restore the full session should use
571    /// [`Self::new_with_session_paths`] instead.
572    fn from_settings(settings: gitkraft_core::AppSettings) -> Self {
573        let current_theme_index = settings
574            .theme_name
575            .as_deref()
576            .map(gitkraft_core::theme_index_by_name)
577            .unwrap_or(0);
578
579        let recent_repos = settings.recent_repos;
580
581        let (
582            sidebar_width,
583            commit_log_width,
584            staging_height,
585            diff_file_list_width,
586            sidebar_expanded,
587            ui_scale,
588        ) = if let Some(ref layout) = settings.layout {
589            (
590                layout.sidebar_width.unwrap_or(220.0),
591                layout.commit_log_width.unwrap_or(500.0),
592                layout.staging_height.unwrap_or(200.0),
593                layout.diff_file_list_width.unwrap_or(180.0),
594                layout.sidebar_expanded.unwrap_or(true),
595                layout.ui_scale.unwrap_or(1.0),
596            )
597        } else {
598            (220.0, 500.0, 200.0, 180.0, true, 1.0)
599        };
600
601        Self {
602            tabs: vec![RepoTab::new_empty()],
603            active_tab: 0,
604
605            sidebar_expanded,
606
607            sidebar_width,
608            commit_log_width,
609            staging_height,
610            diff_file_list_width,
611            sidebar_branches_ratio: 0.6,
612            sidebar_stash_ratio: 0.25,
613            staging_unstaged_ratio: 0.4,
614            staging_staged_ratio: 0.3,
615
616            ui_scale,
617
618            dragging: None,
619            dragging_h: None,
620            drag_start_x: 0.0,
621            drag_start_y: 0.0,
622            drag_initialized: false,
623            drag_initialized_h: false,
624            cursor_pos: Point::ORIGIN,
625
626            current_theme_index,
627
628            recent_repos,
629
630            search_visible: false,
631            search_query: String::new(),
632            search_results: Vec::new(),
633            search_selected: None,
634            search_diff_files: Vec::new(),
635            search_diff_selected: HashSet::new(),
636            search_diff_content: Vec::new(),
637            search_diff_oid: None,
638
639            keyboard_modifiers: iced::keyboard::Modifiers::default(),
640            animation_tick: 0,
641
642            window_width: settings
643                .layout
644                .as_ref()
645                .and_then(|l| l.window_width)
646                .unwrap_or(1400.0),
647            window_height: settings
648                .layout
649                .as_ref()
650                .and_then(|l| l.window_height)
651                .unwrap_or(800.0),
652            window_x: settings
653                .layout
654                .as_ref()
655                .and_then(|l| l.window_x)
656                .unwrap_or(0.0),
657            window_y: settings
658                .layout
659                .as_ref()
660                .and_then(|l| l.window_y)
661                .unwrap_or(0.0),
662
663            editor: settings
664                .editor_name
665                .as_deref()
666                .map(|name| {
667                    // Try to map persisted name back to Editor variant
668                    gitkraft_core::EDITOR_NAMES
669                        .iter()
670                        .position(|n| n.eq_ignore_ascii_case(name))
671                        .map(gitkraft_core::Editor::from_index)
672                        .unwrap_or_else(|| {
673                            if name.eq_ignore_ascii_case("none") {
674                                gitkraft_core::Editor::None
675                            } else {
676                                gitkraft_core::Editor::Custom(name.to_string())
677                            }
678                        })
679                })
680                .unwrap_or_else(detect_system_editor),
681        }
682    }
683
684    /// Create a fresh application state with sensible defaults.
685    ///
686    /// Loads persisted settings (theme, recent repos) from disk when available.
687    /// Always starts with one empty tab — use [`Self::new_with_session_paths`] to
688    /// restore the full multi-tab session.
689    pub fn new() -> Self {
690        Self::from_settings(
691            gitkraft_core::features::persistence::ops::load_settings().unwrap_or_default(),
692        )
693    }
694
695    /// Create state and also return the saved tab paths for startup restore.
696    ///
697    /// Call this from `main.rs` instead of [`Self::new`]; it sets up loading tabs
698    /// for every path in the persisted session and returns those paths so the
699    /// caller can spawn parallel `load_repo_at` tasks.
700    pub fn new_with_session_paths() -> (Self, Vec<PathBuf>) {
701        let settings =
702            gitkraft_core::features::persistence::ops::load_settings().unwrap_or_default();
703        let open_tabs = settings.open_tabs.clone();
704        let active_tab_index = settings.active_tab_index;
705
706        let mut state = Self::from_settings(settings);
707
708        if !open_tabs.is_empty() {
709            state.tabs = open_tabs
710                .iter()
711                .map(|path| {
712                    let mut tab = RepoTab::new_empty();
713                    // Set the path now so the tab bar shows the right name
714                    // while the repo is being loaded in the background.
715                    tab.repo_path = Some(path.clone());
716                    if path.exists() {
717                        tab.is_loading = true;
718                        tab.status_message = Some(format!(
719                            "Loading {}…",
720                            path.file_name().unwrap_or_default().to_string_lossy()
721                        ));
722                    } else {
723                        tab.error_message =
724                            Some(format!("Repository not found: {}", path.display()));
725                    }
726                    tab
727                })
728                .collect();
729            state.active_tab = active_tab_index.min(state.tabs.len().saturating_sub(1));
730        }
731
732        (state, open_tabs)
733    }
734
735    /// Paths of all tabs where a repository has been fully loaded
736    /// (`repo_info` is populated). Used to persist the multi-tab session.
737    pub fn open_tab_paths(&self) -> Vec<PathBuf> {
738        self.tabs
739            .iter()
740            .filter(|t| t.repo_info.is_some())
741            .filter_map(|t| t.repo_path.clone())
742            .collect()
743    }
744
745    /// Get a reference to the currently active tab.
746    pub fn active_tab(&self) -> &RepoTab {
747        &self.tabs[self.active_tab]
748    }
749
750    /// Get a mutable reference to the currently active tab.
751    pub fn active_tab_mut(&mut self) -> &mut RepoTab {
752        &mut self.tabs[self.active_tab]
753    }
754
755    /// Dismiss any open context menu.
756    pub fn dismiss_menu(&mut self) {
757        self.active_tab_mut().context_menu = None;
758    }
759
760    /// Open a context menu at the current cursor position.
761    pub fn open_context_menu(&mut self, menu: ContextMenu) {
762        let pos = (self.cursor_pos.x, self.cursor_pos.y);
763        let tab = self.active_tab_mut();
764        tab.context_menu_pos = pos;
765        tab.context_menu = Some(menu);
766    }
767
768    /// Whether the active tab has a repository open.
769    pub fn has_repo(&self) -> bool {
770        self.active_tab().has_repo()
771    }
772
773    /// Helper: the display name for the active tab's repo.
774    pub fn repo_display_name(&self) -> String {
775        self.active_tab().display_name()
776    }
777
778    /// Derive the full [`ThemeColors`] from the currently active core theme.
779    ///
780    /// Call this at the top of view functions:
781    /// ```ignore
782    /// let c = state.colors();
783    /// ```
784    pub fn colors(&self) -> ThemeColors {
785        ThemeColors::from_core(&gitkraft_core::theme_by_index(self.current_theme_index))
786    }
787
788    /// Return a **custom** `iced::Theme` whose `Palette` is derived from the
789    /// active core theme.
790    ///
791    /// This is the key to making every built-in Iced widget (text inputs,
792    /// pick-lists, scrollbars, buttons without explicit `.style()`, etc.)
793    /// inherit the correct background, text, accent, success and danger
794    /// colours.  Without this, Iced falls back to its generic Dark/Light
795    /// palette and the UI looks wrong for every non-default theme.
796    pub fn iced_theme(&self) -> iced::Theme {
797        let core = gitkraft_core::theme_by_index(self.current_theme_index);
798        let name = self.current_theme_name().to_string();
799
800        let palette = iced::theme::Palette {
801            background: rgb_to_iced(core.background),
802            text: rgb_to_iced(core.text_primary),
803            primary: rgb_to_iced(core.accent),
804            success: rgb_to_iced(core.success),
805            warning: rgb_to_iced(core.warning),
806            danger: rgb_to_iced(core.error),
807        };
808
809        iced::Theme::custom(name, palette)
810    }
811
812    /// The display name of the currently active theme.
813    pub fn current_theme_name(&self) -> &'static str {
814        gitkraft_core::THEME_NAMES
815            .get(self.current_theme_index)
816            .copied()
817            .unwrap_or("Default")
818    }
819
820    /// Refresh all data for the currently active tab's repository.
821    ///
822    /// Returns [`Task::none()`] if no repository is open in the active tab.
823    pub fn refresh_active_tab(&mut self) -> Task<Message> {
824        match self.active_tab().repo_path.clone() {
825            Some(path) => crate::features::repo::commands::refresh_repo(path),
826            None => Task::none(),
827        }
828    }
829
830    /// Handle a `Result<(), String>` from a git operation that should trigger
831    /// a full repository refresh on success.
832    ///
833    /// * `Ok(())` — clears `is_loading`, sets `status_message`, refreshes.
834    /// * `Err(e)` — clears `is_loading`, sets `error_message`, returns
835    ///   [`Task::none()`].
836    pub fn on_ok_refresh(
837        &mut self,
838        result: Result<(), String>,
839        ok_msg: &str,
840        err_prefix: &str,
841    ) -> Task<Message> {
842        match result {
843            Ok(()) => {
844                {
845                    let tab = self.active_tab_mut();
846                    tab.is_loading = false;
847                    tab.status_message = Some(ok_msg.to_string());
848                }
849                self.refresh_active_tab()
850            }
851            Err(e) => {
852                let tab = self.active_tab_mut();
853                tab.is_loading = false;
854                tab.error_message = Some(format!("{err_prefix}: {e}"));
855                tab.status_message = None;
856                Task::none()
857            }
858        }
859    }
860
861    /// Build a [`LayoutSettings`] snapshot from the current pane dimensions.
862    pub fn current_layout(&self) -> gitkraft_core::LayoutSettings {
863        gitkraft_core::LayoutSettings {
864            sidebar_width: Some(self.sidebar_width),
865            commit_log_width: Some(self.commit_log_width),
866            staging_height: Some(self.staging_height),
867            diff_file_list_width: Some(self.diff_file_list_width),
868            sidebar_expanded: Some(self.sidebar_expanded),
869            ui_scale: Some(self.ui_scale),
870            window_width: Some(self.window_width),
871            window_height: Some(self.window_height),
872            window_x: Some(self.window_x),
873            window_y: Some(self.window_y),
874            window_maximized: None, // not tracked
875        }
876    }
877}
878
879/// Convert a core [`gitkraft_core::Rgb`] to an [`iced::Color`].
880fn rgb_to_iced(rgb: gitkraft_core::Rgb) -> Color {
881    Color::from_rgb8(rgb.r, rgb.g, rgb.b)
882}
883
884/// Try to detect the system's preferred editor from environment variables.
885fn detect_system_editor() -> gitkraft_core::Editor {
886    for var in ["VISUAL", "EDITOR"] {
887        if let Ok(val) = std::env::var(var) {
888            let bin = val.split('/').next_back().unwrap_or(&val).trim();
889            return match bin {
890                "nvim" | "neovim" => gitkraft_core::Editor::Neovim,
891                "vim" => gitkraft_core::Editor::Vim,
892                "hx" | "helix" => gitkraft_core::Editor::Helix,
893                "nano" => gitkraft_core::Editor::Nano,
894                "micro" => gitkraft_core::Editor::Micro,
895                "emacs" => gitkraft_core::Editor::Emacs,
896                "code" => gitkraft_core::Editor::VSCode,
897                "zed" => gitkraft_core::Editor::Zed,
898                "subl" => gitkraft_core::Editor::Sublime,
899                _ => gitkraft_core::Editor::Custom(val),
900            };
901        }
902    }
903    gitkraft_core::Editor::None
904}
905
906// ── Tests ─────────────────────────────────────────────────────────────────────
907
908#[cfg(test)]
909mod tests {
910    use super::*;
911
912    #[test]
913    fn new_defaults() {
914        let state = GitKraft::new();
915        assert!(state.active_tab().repo_path.is_none());
916        assert!(!state.has_repo());
917        assert_eq!(state.repo_display_name(), "New Tab");
918        assert!(state.active_tab().commits.is_empty());
919        assert!(state.sidebar_expanded);
920        // Default theme index should be valid
921        assert!(state.current_theme_index < gitkraft_core::THEME_COUNT);
922        // Pane defaults
923        assert!(state.sidebar_width > 0.0);
924        assert!(state.commit_log_width > 0.0);
925        assert!(state.staging_height > 0.0);
926        assert!(state.dragging.is_none());
927        assert!(state.dragging_h.is_none());
928        // Should start with one empty tab
929        assert_eq!(state.tabs.len(), 1);
930        assert_eq!(state.active_tab, 0);
931    }
932
933    #[test]
934    fn repo_display_name_extracts_basename() {
935        let mut state = GitKraft::new();
936        state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/home/user/my-project"));
937        assert_eq!(state.repo_display_name(), "my-project");
938    }
939
940    #[test]
941    fn colors_returns_theme_colors() {
942        let state = GitKraft::new();
943        let c = state.colors();
944        // The default theme (index 0) is dark, so background should be dark
945        assert!(c.bg.r < 0.5);
946    }
947
948    #[test]
949    fn iced_theme_is_custom_with_correct_palette() {
950        let mut state = GitKraft::new();
951
952        // Index 0 = Default (dark) — custom theme with dark background
953        state.current_theme_index = 0;
954        let iced_t = state.iced_theme();
955        let pal = iced_t.palette();
956        assert!(pal.background.r < 0.5, "Default theme bg should be dark");
957        assert_eq!(iced_t.to_string(), "Default");
958
959        // Solarized Light — custom theme with light background
960        let sol_idx = gitkraft_core::theme_index_by_name("Solarized Light");
961        state.current_theme_index = sol_idx;
962        let iced_t = state.iced_theme();
963        let pal = iced_t.palette();
964        assert!(pal.background.r > 0.5, "Solarized Light bg should be light");
965        assert_eq!(iced_t.to_string(), "Solarized Light");
966
967        // Gruvbox Dark — accent should come from core
968        let gruvbox_idx = gitkraft_core::theme_index_by_name("Gruvbox Dark");
969        state.current_theme_index = gruvbox_idx;
970        let iced_t = state.iced_theme();
971        let pal = iced_t.palette();
972        let core = gitkraft_core::theme_by_index(gruvbox_idx);
973        let expected_accent = rgb_to_iced(core.accent);
974        assert!(
975            (pal.primary.r - expected_accent.r).abs() < 0.01
976                && (pal.primary.g - expected_accent.g).abs() < 0.01
977                && (pal.primary.b - expected_accent.b).abs() < 0.01,
978            "Gruvbox Dark accent should match core accent"
979        );
980    }
981
982    #[test]
983    fn iced_theme_name_round_trips_through_core() {
984        // Ensure the custom theme name matches a core THEME_NAMES entry so
985        // that ThemeColors::from_theme() can map it back to the right index.
986        for i in 0..gitkraft_core::THEME_COUNT {
987            let mut state = GitKraft::new();
988            state.current_theme_index = i;
989            let iced_t = state.iced_theme();
990            let name = iced_t.to_string();
991            let resolved = gitkraft_core::theme_index_by_name(&name);
992            assert_eq!(
993                resolved,
994                i,
995                "theme index {i} ({}) did not round-trip through iced_theme name",
996                gitkraft_core::THEME_NAMES[i]
997            );
998        }
999    }
1000
1001    #[test]
1002    fn current_theme_name_round_trips() {
1003        let mut state = GitKraft::new();
1004        state.current_theme_index = gitkraft_core::theme_index_by_name("Dracula");
1005        assert_eq!(state.current_theme_name(), "Dracula");
1006        state.current_theme_index = 0;
1007        assert_eq!(state.current_theme_name(), "Default");
1008    }
1009
1010    #[test]
1011    fn repo_tab_new_empty() {
1012        let tab = RepoTab::new_empty();
1013        assert!(tab.repo_path.is_none());
1014        assert!(!tab.has_repo());
1015        assert_eq!(tab.display_name(), "New Tab");
1016        assert!(tab.commits.is_empty());
1017        assert!(tab.branches.is_empty());
1018        assert!(!tab.is_loading);
1019    }
1020
1021    #[test]
1022    fn repo_tab_display_name_with_path() {
1023        let mut tab = RepoTab::new_empty();
1024        tab.repo_path = Some(std::path::PathBuf::from("/some/path/cool-repo"));
1025        assert!(tab.has_repo());
1026        assert_eq!(tab.display_name(), "cool-repo");
1027    }
1028
1029    #[test]
1030    fn search_defaults() {
1031        let state = GitKraft::new();
1032        assert!(!state.search_visible);
1033        assert!(state.search_query.is_empty());
1034        assert!(state.search_results.is_empty());
1035        assert!(state.search_selected.is_none());
1036    }
1037
1038    #[test]
1039    fn context_menu_variants_exist() {
1040        // Verify all context menu variants can be constructed
1041        use crate::state::ContextMenu;
1042
1043        let _branch = ContextMenu::Branch {
1044            name: "main".to_string(),
1045            is_current: true,
1046            local_index: 0,
1047        };
1048        let _remote = ContextMenu::RemoteBranch {
1049            name: "origin/main".to_string(),
1050        };
1051        let _commit = ContextMenu::Commit {
1052            index: 0,
1053            oid: "abc1234".to_string(),
1054        };
1055        let _stash = ContextMenu::Stash { index: 0 };
1056        let _unstaged = ContextMenu::UnstagedFile {
1057            path: "src/main.rs".to_string(),
1058        };
1059        let _staged = ContextMenu::StagedFile {
1060            path: "src/lib.rs".to_string(),
1061        };
1062    }
1063
1064    #[test]
1065    fn repo_tab_context_menu_defaults_to_none() {
1066        let tab = crate::state::RepoTab::new_empty();
1067        assert!(tab.context_menu.is_none());
1068    }
1069
1070    #[test]
1071    fn context_menu_variants_constructable() {
1072        use crate::state::ContextMenu;
1073        let _ = ContextMenu::Stash { index: 0 };
1074        let _ = ContextMenu::UnstagedFile {
1075            path: "a.rs".into(),
1076        };
1077        let _ = ContextMenu::StagedFile {
1078            path: "b.rs".into(),
1079        };
1080    }
1081
1082    #[test]
1083    fn selected_unstaged_defaults_empty() {
1084        let tab = crate::state::RepoTab::new_empty();
1085        assert!(tab.selected_unstaged.is_empty());
1086        assert!(tab.selected_staged.is_empty());
1087    }
1088
1089    #[test]
1090    fn selected_unstaged_toggle() {
1091        let mut tab = crate::state::RepoTab::new_empty();
1092        tab.selected_unstaged.insert("a.rs".to_string());
1093        tab.selected_unstaged.insert("b.rs".to_string());
1094        assert_eq!(tab.selected_unstaged.len(), 2);
1095        assert!(tab.selected_unstaged.contains("a.rs"));
1096        tab.selected_unstaged.remove("a.rs");
1097        assert_eq!(tab.selected_unstaged.len(), 1);
1098        assert!(!tab.selected_unstaged.contains("a.rs"));
1099    }
1100
1101    #[test]
1102    fn detect_system_editor_returns_valid() {
1103        // Just verify it doesn't panic
1104        let editor = super::detect_system_editor();
1105        let _ = editor.display_name();
1106    }
1107
1108    // ── Multi-file commit diff selection ──────────────────────────────────
1109
1110    #[test]
1111    fn selected_commit_file_indices_defaults_to_empty_vec() {
1112        let tab = RepoTab::new_empty();
1113        assert!(tab.selected_commit_file_indices.is_empty());
1114        // Must be a Vec (ordered), not a HashSet — check it supports indexing
1115        let v: &Vec<usize> = &tab.selected_commit_file_indices;
1116        assert_eq!(v.len(), 0);
1117    }
1118
1119    #[test]
1120    fn multi_file_diffs_defaults_empty() {
1121        let tab = RepoTab::new_empty();
1122        assert!(tab.multi_file_diffs.is_empty());
1123    }
1124
1125    #[test]
1126    fn keyboard_modifiers_default_has_no_shift() {
1127        let state = GitKraft::new();
1128        assert!(!state.keyboard_modifiers.shift());
1129    }
1130
1131    #[test]
1132    fn selected_commit_file_indices_preserves_insertion_order() {
1133        let mut tab = RepoTab::new_empty();
1134        tab.selected_commit_file_indices.push(5);
1135        tab.selected_commit_file_indices.push(2);
1136        tab.selected_commit_file_indices.push(8);
1137        assert_eq!(tab.selected_commit_file_indices, vec![5, 2, 8]);
1138    }
1139
1140    #[test]
1141    fn selected_commit_file_indices_cleared_on_reset() {
1142        let mut tab = RepoTab::new_empty();
1143        tab.selected_commit_file_indices.push(0);
1144        tab.selected_commit_file_indices.push(1);
1145        tab.selected_commit_file_indices.clear();
1146        assert!(tab.selected_commit_file_indices.is_empty());
1147    }
1148
1149    #[test]
1150    fn multi_file_diffs_cleared_on_reset() {
1151        let mut tab = RepoTab::new_empty();
1152        tab.multi_file_diffs.push(gitkraft_core::DiffInfo {
1153            old_file: String::new(),
1154            new_file: "a.rs".to_string(),
1155            status: gitkraft_core::FileStatus::Modified,
1156            hunks: vec![],
1157        });
1158        tab.multi_file_diffs.clear();
1159        assert!(tab.multi_file_diffs.is_empty());
1160    }
1161
1162    #[test]
1163    fn commit_range_diffs_defaults_empty() {
1164        let tab = RepoTab::new_empty();
1165        assert!(tab.commit_range_diffs.is_empty());
1166    }
1167
1168    #[test]
1169    fn commit_range_diffs_cleared_on_apply_payload() {
1170        // verify the field is reset — just check it's accessible and clearable
1171        let mut tab = RepoTab::new_empty();
1172        tab.commit_range_diffs.push(gitkraft_core::DiffInfo {
1173            old_file: String::new(),
1174            new_file: "x.rs".to_string(),
1175            status: gitkraft_core::FileStatus::Modified,
1176            hunks: vec![],
1177        });
1178        tab.commit_range_diffs.clear();
1179        assert!(tab.commit_range_diffs.is_empty());
1180    }
1181
1182    // ── ModifiersChanged update ───────────────────────────────────────────
1183
1184    #[test]
1185    fn modifiers_changed_sets_shift_state() {
1186        use crate::message::Message;
1187        let mut state = GitKraft::new();
1188        assert!(!state.keyboard_modifiers.shift());
1189
1190        let _ = state.update(Message::ModifiersChanged(iced::keyboard::Modifiers::SHIFT));
1191        assert!(state.keyboard_modifiers.shift());
1192
1193        let _ = state.update(Message::ModifiersChanged(
1194            iced::keyboard::Modifiers::default(),
1195        ));
1196        assert!(!state.keyboard_modifiers.shift());
1197    }
1198
1199    // ── SelectDiffByIndex update ──────────────────────────────────────────
1200
1201    fn make_commit_files(names: &[&str]) -> Vec<gitkraft_core::DiffFileEntry> {
1202        names
1203            .iter()
1204            .map(|name| gitkraft_core::DiffFileEntry {
1205                old_file: String::new(),
1206                new_file: name.to_string(),
1207                status: gitkraft_core::FileStatus::Modified,
1208            })
1209            .collect()
1210    }
1211
1212    #[test]
1213    fn select_diff_by_index_regular_click_clears_multi_selection() {
1214        use crate::message::Message;
1215        let mut state = GitKraft::new();
1216        // Provide a repo_path and oid so the update handler can reach the
1217        // `selected_file_index = Some(index)` assignment (the async task it
1218        // spawns is dropped without execution — no real repo is needed).
1219        state.active_tab_mut().repo_path =
1220            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1221        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1222        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1223        // Pre-populate a multi-selection
1224        state.active_tab_mut().selected_commit_file_indices = vec![0, 1];
1225
1226        // Regular click (no Shift) — should collapse to single-file selection
1227        let _ = state.update(Message::SelectDiffByIndex(0));
1228
1229        assert!(state.active_tab().selected_commit_file_indices.is_empty());
1230        assert_eq!(state.active_tab().selected_file_index, Some(0));
1231    }
1232
1233    #[test]
1234    fn regular_click_preserves_multi_file_diffs_until_load_completes() {
1235        use crate::message::Message;
1236        let mut state = GitKraft::new();
1237        state.active_tab_mut().repo_path =
1238            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1239        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1240        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1241
1242        // Pre-populate multi_file_diffs as if user had a multi-file selection
1243        state.active_tab_mut().multi_file_diffs = vec![gitkraft_core::DiffInfo {
1244            old_file: String::new(),
1245            new_file: "a.rs".to_string(),
1246            status: gitkraft_core::FileStatus::Modified,
1247            hunks: vec![],
1248        }];
1249
1250        // Regular click — should NOT clear multi_file_diffs immediately
1251        // (deferred to SingleFileDiffLoaded to avoid visual blink).
1252        let _ = state.update(Message::SelectDiffByIndex(1));
1253
1254        assert!(
1255            !state.active_tab().multi_file_diffs.is_empty(),
1256            "multi_file_diffs must NOT be cleared on click (deferred to diff load)"
1257        );
1258        assert_eq!(state.active_tab().selected_file_index, Some(1));
1259        assert!(state.active_tab().is_loading_file_diff);
1260    }
1261
1262    #[test]
1263    fn regular_click_preserves_diff_scroll_until_load_completes() {
1264        use crate::message::Message;
1265        let mut state = GitKraft::new();
1266        state.active_tab_mut().repo_path =
1267            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1268        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1269        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs"]);
1270        state.active_tab_mut().diff_scroll_offset = 150.0;
1271
1272        // Regular click — should NOT reset diff_scroll_offset immediately.
1273        let _ = state.update(Message::SelectDiffByIndex(1));
1274
1275        assert_eq!(
1276            state.active_tab().diff_scroll_offset,
1277            150.0,
1278            "diff_scroll_offset must NOT be reset on click (deferred to diff load)"
1279        );
1280    }
1281
1282    #[test]
1283    fn single_file_diff_loaded_clears_deferred_state() {
1284        use crate::message::Message;
1285        let mut state = GitKraft::new();
1286        state.active_tab_mut().repo_path =
1287            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1288        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1289        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs"]);
1290
1291        // Pre-populate multi_file_diffs and commit_range_diffs
1292        state.active_tab_mut().multi_file_diffs = vec![gitkraft_core::DiffInfo {
1293            old_file: String::new(),
1294            new_file: "a.rs".to_string(),
1295            status: gitkraft_core::FileStatus::Modified,
1296            hunks: vec![],
1297        }];
1298        state.active_tab_mut().commit_range_diffs = vec![gitkraft_core::DiffInfo {
1299            old_file: String::new(),
1300            new_file: "b.rs".to_string(),
1301            status: gitkraft_core::FileStatus::Modified,
1302            hunks: vec![],
1303        }];
1304        state.active_tab_mut().is_loading_file_diff = true;
1305        state.active_tab_mut().diff_scroll_offset = 200.0;
1306
1307        // Simulate SingleFileDiffLoaded arriving with the new diff.
1308        let new_diff = gitkraft_core::DiffInfo {
1309            old_file: String::new(),
1310            new_file: "b.rs".to_string(),
1311            status: gitkraft_core::FileStatus::Modified,
1312            hunks: vec![],
1313        };
1314        let _ = state.update(Message::SingleFileDiffLoaded(Ok(new_diff)));
1315
1316        // All deferred state should now be cleared.
1317        assert!(
1318            state.active_tab().multi_file_diffs.is_empty(),
1319            "multi_file_diffs must be cleared when new diff arrives"
1320        );
1321        assert!(
1322            state.active_tab().commit_range_diffs.is_empty(),
1323            "commit_range_diffs must be cleared when new diff arrives"
1324        );
1325        assert!(
1326            state.active_tab().selected_diff.is_some(),
1327            "selected_diff must be set to the loaded diff"
1328        );
1329        assert!(
1330            !state.active_tab().is_loading_file_diff,
1331            "is_loading_file_diff must be cleared after load"
1332        );
1333        assert_eq!(
1334            state.active_tab().diff_scroll_offset,
1335            0.0,
1336            "diff_scroll_offset must be reset when new diff arrives"
1337        );
1338    }
1339
1340    #[test]
1341    fn regular_click_preserves_commit_range_diffs_until_load_completes() {
1342        use crate::message::Message;
1343        let mut state = GitKraft::new();
1344        state.active_tab_mut().repo_path =
1345            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1346        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1347        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs"]);
1348
1349        // Pre-populate commit_range_diffs
1350        state.active_tab_mut().commit_range_diffs = vec![gitkraft_core::DiffInfo {
1351            old_file: String::new(),
1352            new_file: "x.rs".to_string(),
1353            status: gitkraft_core::FileStatus::Modified,
1354            hunks: vec![],
1355        }];
1356
1357        let _ = state.update(Message::SelectDiffByIndex(0));
1358
1359        assert!(
1360            !state.active_tab().commit_range_diffs.is_empty(),
1361            "commit_range_diffs must NOT be cleared on click (deferred to diff load)"
1362        );
1363    }
1364
1365    #[test]
1366    fn select_diff_by_index_shift_click_adds_both_files_to_selection() {
1367        use crate::message::Message;
1368        let mut state = GitKraft::new();
1369        state.active_tab_mut().repo_path =
1370            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1371        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1372        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1373        state.active_tab_mut().selected_file_index = Some(0);
1374
1375        // Shift+Click on file 1 should anchor 0 and add 1
1376        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1377        let _ = state.update(Message::SelectDiffByIndex(1));
1378
1379        let sel = &state.active_tab().selected_commit_file_indices;
1380        assert!(sel.contains(&0), "anchor file 0 should be selected");
1381        assert!(sel.contains(&1), "newly clicked file 1 should be selected");
1382        assert_eq!(sel.len(), 2);
1383    }
1384
1385    #[test]
1386    fn anchor_file_index_defaults_to_none() {
1387        let tab = RepoTab::new_empty();
1388        assert!(tab.anchor_file_index.is_none());
1389    }
1390
1391    #[test]
1392    fn regular_click_sets_anchor() {
1393        use crate::message::Message;
1394        let mut state = GitKraft::new();
1395        state.active_tab_mut().repo_path =
1396            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1397        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1398        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1399
1400        let _ = state.update(Message::SelectDiffByIndex(2));
1401
1402        assert_eq!(
1403            state.active_tab().anchor_file_index,
1404            Some(2),
1405            "regular click must set anchor to the clicked index"
1406        );
1407    }
1408
1409    #[test]
1410    fn shift_click_selects_range_downward_from_anchor() {
1411        use crate::message::Message;
1412        let mut state = GitKraft::new();
1413        state.active_tab_mut().repo_path =
1414            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1415        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1416        state.active_tab_mut().commit_files =
1417            make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1418        // Anchor at index 1
1419        state.active_tab_mut().anchor_file_index = Some(1);
1420        state.active_tab_mut().selected_file_index = Some(1);
1421
1422        // Shift+Click on index 4 — should select 1, 2, 3, 4
1423        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1424        let _ = state.update(Message::SelectDiffByIndex(4));
1425
1426        let sel = &state.active_tab().selected_commit_file_indices;
1427        assert_eq!(
1428            sel,
1429            &vec![1, 2, 3, 4],
1430            "range must be contiguous from anchor to click"
1431        );
1432    }
1433
1434    #[test]
1435    fn shift_click_selects_range_upward_from_anchor() {
1436        use crate::message::Message;
1437        let mut state = GitKraft::new();
1438        state.active_tab_mut().repo_path =
1439            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1440        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1441        state.active_tab_mut().commit_files =
1442            make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1443        // Anchor at index 4 (bottom)
1444        state.active_tab_mut().anchor_file_index = Some(4);
1445        state.active_tab_mut().selected_file_index = Some(4);
1446
1447        // Shift+Click on index 1 — should select 1, 2, 3, 4 (ascending)
1448        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1449        let _ = state.update(Message::SelectDiffByIndex(1));
1450
1451        let sel = &state.active_tab().selected_commit_file_indices;
1452        assert_eq!(
1453            sel,
1454            &vec![1, 2, 3, 4],
1455            "range must be stored ascending regardless of click direction"
1456        );
1457    }
1458
1459    #[test]
1460    fn shift_click_anchor_fixed_on_subsequent_clicks() {
1461        use crate::message::Message;
1462        let mut state = GitKraft::new();
1463        state.active_tab_mut().repo_path =
1464            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1465        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1466        state.active_tab_mut().commit_files =
1467            make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1468        // Anchor at index 2
1469        state.active_tab_mut().anchor_file_index = Some(2);
1470        state.active_tab_mut().selected_file_index = Some(2);
1471        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1472
1473        // First Shift+Click: extend to 4 → range {2, 3, 4}
1474        let _ = state.update(Message::SelectDiffByIndex(4));
1475        assert_eq!(
1476            state.active_tab().selected_commit_file_indices,
1477            vec![2, 3, 4]
1478        );
1479
1480        // Second Shift+Click: shrink back to 3 → range {2, 3} (anchor still 2)
1481        let _ = state.update(Message::SelectDiffByIndex(3));
1482        assert_eq!(
1483            state.active_tab().selected_commit_file_indices,
1484            vec![2, 3],
1485            "anchor must stay fixed; second Shift+Click shrinks the range"
1486        );
1487
1488        // Third Shift+Click: extend upward → range {0, 1, 2} (anchor still 2)
1489        let _ = state.update(Message::SelectDiffByIndex(0));
1490        assert_eq!(
1491            state.active_tab().selected_commit_file_indices,
1492            vec![0, 1, 2],
1493            "anchor must stay fixed; can extend range in either direction"
1494        );
1495    }
1496
1497    #[test]
1498    fn shift_click_on_anchor_itself_gives_single_item_range() {
1499        use crate::message::Message;
1500        let mut state = GitKraft::new();
1501        state.active_tab_mut().repo_path =
1502            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1503        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1504        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1505        state.active_tab_mut().anchor_file_index = Some(1);
1506        state.active_tab_mut().selected_file_index = Some(1);
1507
1508        // Shift+Click on the anchor itself → single-item range {1}
1509        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1510        let _ = state.update(Message::SelectDiffByIndex(1));
1511
1512        assert_eq!(state.active_tab().selected_commit_file_indices, vec![1]);
1513        assert!(
1514            state.active_tab().multi_file_diffs.is_empty(),
1515            "single-item range must not populate multi_file_diffs"
1516        );
1517    }
1518
1519    #[test]
1520    fn shift_click_range_is_always_ascending() {
1521        use crate::message::Message;
1522        let mut state = GitKraft::new();
1523        state.active_tab_mut().repo_path =
1524            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1525        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1526        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs"]);
1527        state.active_tab_mut().anchor_file_index = Some(3);
1528        state.active_tab_mut().selected_file_index = Some(3);
1529
1530        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1531        let _ = state.update(Message::SelectDiffByIndex(0));
1532
1533        let sel = &state.active_tab().selected_commit_file_indices;
1534        let is_sorted = sel.windows(2).all(|w| w[0] < w[1]);
1535        assert!(
1536            is_sorted,
1537            "selection must always be stored in ascending order"
1538        );
1539        assert_eq!(sel, &vec![0, 1, 2, 3]);
1540    }
1541
1542    #[test]
1543    fn checkout_file_at_commit_message_variants_exist() {
1544        use crate::message::Message;
1545        // Verify the new message variants can be constructed
1546        let _single =
1547            Message::CheckoutFileAtCommit("abc123".to_string(), "src/main.rs".to_string());
1548        let _multi = Message::CheckoutMultiFilesAtCommit(
1549            "abc123".to_string(),
1550            vec!["a.rs".to_string(), "b.rs".to_string()],
1551        );
1552    }
1553
1554    #[test]
1555    fn checkout_file_at_commit_closes_context_menu() {
1556        use crate::message::Message;
1557        let mut state = GitKraft::new();
1558        state.active_tab_mut().repo_path =
1559            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1560        state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::CommitFile {
1561            oid: "abc123".to_string(),
1562            file_path: "src/main.rs".to_string(),
1563        });
1564        let _ = state.update(Message::CheckoutFileAtCommit(
1565            "abc123".to_string(),
1566            "src/main.rs".to_string(),
1567        ));
1568        assert!(state.active_tab().context_menu.is_none());
1569    }
1570
1571    #[test]
1572    fn checkout_multi_files_at_commit_closes_context_menu() {
1573        use crate::message::Message;
1574        let mut state = GitKraft::new();
1575        state.active_tab_mut().repo_path =
1576            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1577        state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::CommitFile {
1578            oid: "abc123".to_string(),
1579            file_path: "src/main.rs".to_string(),
1580        });
1581        let _ = state.update(Message::CheckoutMultiFilesAtCommit(
1582            "abc123".to_string(),
1583            vec!["src/main.rs".to_string(), "src/lib.rs".to_string()],
1584        ));
1585        assert!(state.active_tab().context_menu.is_none());
1586    }
1587
1588    // ── Commit multi-selection ────────────────────────────────────────────
1589
1590    fn make_test_commits(count: usize) -> Vec<gitkraft_core::CommitInfo> {
1591        (0..count)
1592            .map(|i| gitkraft_core::CommitInfo {
1593                oid: i.to_string(),
1594                short_oid: i.to_string(),
1595                summary: String::new(),
1596                message: String::new(),
1597                author_name: String::new(),
1598                author_email: String::new(),
1599                time: Default::default(),
1600                parent_ids: Vec::new(),
1601                refs: Vec::new(),
1602            })
1603            .collect()
1604    }
1605
1606    #[test]
1607    fn selected_commits_defaults_empty() {
1608        let tab = RepoTab::new_empty();
1609        assert!(tab.selected_commits.is_empty());
1610        assert!(tab.anchor_commit_index.is_none());
1611    }
1612
1613    #[test]
1614    fn regular_click_commit_sets_anchor_and_clears_range() {
1615        use crate::message::Message;
1616        let mut state = GitKraft::new();
1617        state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake"));
1618        state.active_tab_mut().commits = make_test_commits(3);
1619        state.active_tab_mut().selected_commits = vec![0, 1, 2];
1620
1621        let _ = state.update(Message::SelectCommit(1));
1622
1623        assert_eq!(state.active_tab().anchor_commit_index, Some(1));
1624        assert!(state.active_tab().selected_commits.is_empty());
1625        assert_eq!(state.active_tab().selected_commit, Some(1));
1626    }
1627
1628    #[test]
1629    fn shift_click_commit_selects_range_from_anchor() {
1630        use crate::message::Message;
1631        let mut state = GitKraft::new();
1632        state.active_tab_mut().commits = make_test_commits(5);
1633        state.active_tab_mut().anchor_commit_index = Some(1);
1634        state.active_tab_mut().selected_commit = Some(1);
1635
1636        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1637        let _ = state.update(Message::SelectCommit(4));
1638
1639        assert_eq!(state.active_tab().selected_commits, vec![1, 2, 3, 4]);
1640    }
1641
1642    #[test]
1643    fn shift_click_commit_range_is_ascending_when_clicking_above_anchor() {
1644        use crate::message::Message;
1645        let mut state = GitKraft::new();
1646        state.active_tab_mut().commits = make_test_commits(5);
1647        state.active_tab_mut().anchor_commit_index = Some(3);
1648        state.active_tab_mut().selected_commit = Some(3);
1649
1650        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1651        let _ = state.update(Message::SelectCommit(1));
1652
1653        assert_eq!(state.active_tab().selected_commits, vec![1, 2, 3]);
1654    }
1655
1656    // ── ExecuteCommitAction message ───────────────────────────────────────
1657
1658    #[test]
1659    fn execute_commit_action_closes_context_menu() {
1660        use crate::message::Message;
1661        let mut state = GitKraft::new();
1662        state.active_tab_mut().repo_path =
1663            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1664        state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::Commit {
1665            index: 0,
1666            oid: "abc123".to_string(),
1667        });
1668
1669        let _ = state.update(Message::ExecuteCommitAction(
1670            "abc123".to_string(),
1671            gitkraft_core::CommitAction::CherryPick,
1672        ));
1673
1674        assert!(state.active_tab().context_menu.is_none());
1675    }
1676
1677    #[test]
1678    fn execute_commit_action_sets_loading_when_repo_open() {
1679        use crate::message::Message;
1680        let mut state = GitKraft::new();
1681        state.active_tab_mut().repo_path =
1682            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1683
1684        let _ = state.update(Message::ExecuteCommitAction(
1685            "abc123".to_string(),
1686            gitkraft_core::CommitAction::ResetHard,
1687        ));
1688
1689        assert!(state.active_tab().is_loading);
1690    }
1691
1692    #[test]
1693    fn execute_commit_action_no_repo_does_not_set_loading() {
1694        use crate::message::Message;
1695        let mut state = GitKraft::new();
1696        // No repo_path set
1697
1698        let _ = state.update(Message::ExecuteCommitAction(
1699            "abc123".to_string(),
1700            gitkraft_core::CommitAction::CherryPick,
1701        ));
1702
1703        assert!(!state.active_tab().is_loading);
1704    }
1705
1706    #[test]
1707    fn execute_commit_action_sets_status_message_from_action_label() {
1708        use crate::message::Message;
1709        let mut state = GitKraft::new();
1710        state.active_tab_mut().repo_path =
1711            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1712
1713        let _ = state.update(Message::ExecuteCommitAction(
1714            "abc123".to_string(),
1715            gitkraft_core::CommitAction::Revert,
1716        ));
1717
1718        let status = state.active_tab().status_message.as_deref().unwrap_or("");
1719        // Status message should contain the action's label
1720        assert!(
1721            status.contains("Revert commit"),
1722            "expected status to contain 'Revert commit', got: {status:?}"
1723        );
1724    }
1725
1726    // ── File history / blame / delete state ──────────────────────────────
1727
1728    #[test]
1729    fn file_history_defaults_empty() {
1730        let tab = RepoTab::new_empty();
1731        assert!(tab.file_history_path.is_none());
1732        assert!(tab.file_history_commits.is_empty());
1733        assert_eq!(tab.file_history_scroll, 0.0);
1734    }
1735
1736    #[test]
1737    fn blame_defaults_empty() {
1738        let tab = RepoTab::new_empty();
1739        assert!(tab.blame_path.is_none());
1740        assert!(tab.blame_lines.is_empty());
1741        assert_eq!(tab.blame_scroll, 0.0);
1742    }
1743
1744    #[test]
1745    fn pending_delete_file_defaults_none() {
1746        let tab = RepoTab::new_empty();
1747        assert!(tab.pending_delete_file.is_none());
1748    }
1749
1750    #[test]
1751    fn view_file_history_sets_path_and_clears_blame() {
1752        use crate::message::Message;
1753        let mut state = GitKraft::new();
1754        state.active_tab_mut().repo_path =
1755            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1756        state.active_tab_mut().blame_path = Some("some/file.rs".to_string());
1757
1758        let _ = state.update(Message::ViewFileHistory("src/main.rs".to_string()));
1759
1760        assert_eq!(
1761            state.active_tab().file_history_path.as_deref(),
1762            Some("src/main.rs")
1763        );
1764        // Opening history should close blame
1765        assert!(state.active_tab().blame_path.is_none());
1766    }
1767
1768    #[test]
1769    fn close_file_history_clears_state() {
1770        use crate::message::Message;
1771        let mut state = GitKraft::new();
1772        state.active_tab_mut().file_history_path = Some("src/lib.rs".to_string());
1773        state.active_tab_mut().file_history_commits = vec![gitkraft_core::CommitInfo {
1774            oid: "abc".to_string(),
1775            short_oid: "abc".to_string(),
1776            summary: "s".to_string(),
1777            message: "s".to_string(),
1778            author_name: "a".to_string(),
1779            author_email: "a@b.com".to_string(),
1780            time: Default::default(),
1781            parent_ids: vec![],
1782            refs: Vec::new(),
1783        }];
1784
1785        let _ = state.update(Message::CloseFileHistory);
1786
1787        assert!(state.active_tab().file_history_path.is_none());
1788        assert!(state.active_tab().file_history_commits.is_empty());
1789    }
1790
1791    #[test]
1792    fn view_file_blame_sets_path_and_clears_history() {
1793        use crate::message::Message;
1794        let mut state = GitKraft::new();
1795        state.active_tab_mut().repo_path =
1796            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1797        state.active_tab_mut().file_history_path = Some("some/file.rs".to_string());
1798
1799        let _ = state.update(Message::ViewFileBlame("src/lib.rs".to_string()));
1800
1801        assert_eq!(state.active_tab().blame_path.as_deref(), Some("src/lib.rs"));
1802        // Opening blame should close history
1803        assert!(state.active_tab().file_history_path.is_none());
1804    }
1805
1806    #[test]
1807    fn selecting_new_commit_closes_blame_overlay() {
1808        use crate::message::Message;
1809        let mut state = GitKraft::new();
1810        state.active_tab_mut().repo_path =
1811            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1812        // Pre-populate a commit list so SelectCommit can find the commit.
1813        state.active_tab_mut().commits = vec![
1814            gitkraft_core::CommitInfo {
1815                oid: "abc1".into(),
1816                short_oid: "abc1".into(),
1817                summary: "first".into(),
1818                message: "first".into(),
1819                author_name: "A".into(),
1820                author_email: "a@a.com".into(),
1821                time: Default::default(),
1822                parent_ids: Vec::new(),
1823                refs: Vec::new(),
1824            },
1825            gitkraft_core::CommitInfo {
1826                oid: "abc2".into(),
1827                short_oid: "abc2".into(),
1828                summary: "second".into(),
1829                message: "second".into(),
1830                author_name: "A".into(),
1831                author_email: "a@a.com".into(),
1832                time: Default::default(),
1833                parent_ids: Vec::new(),
1834                refs: Vec::new(),
1835            },
1836        ];
1837        // Blame is currently open for a file from the first commit.
1838        state.active_tab_mut().blame_path = Some("src/lib.rs".to_string());
1839        state.active_tab_mut().blame_lines = vec![gitkraft_core::BlameLine {
1840            line_number: 1,
1841            content: "fn main() {}".into(),
1842            short_oid: "abc1".into(),
1843            oid: "abc1".into(),
1844            author_name: "A".into(),
1845            time: Default::default(),
1846        }];
1847
1848        // Click a different commit — blame must close automatically.
1849        let _ = state.update(Message::SelectCommit(1));
1850
1851        assert!(
1852            state.active_tab().blame_path.is_none(),
1853            "blame_path must be cleared when a new commit is selected"
1854        );
1855        assert!(
1856            state.active_tab().blame_lines.is_empty(),
1857            "blame_lines must be cleared when a new commit is selected"
1858        );
1859    }
1860
1861    #[test]
1862    fn close_file_blame_clears_state() {
1863        use crate::message::Message;
1864        let mut state = GitKraft::new();
1865        state.active_tab_mut().blame_path = Some("src/lib.rs".to_string());
1866
1867        let _ = state.update(Message::CloseFileBlame);
1868
1869        assert!(state.active_tab().blame_path.is_none());
1870        assert!(state.active_tab().blame_lines.is_empty());
1871    }
1872
1873    #[test]
1874    fn delete_file_sets_pending() {
1875        use crate::message::Message;
1876        let mut state = GitKraft::new();
1877
1878        let _ = state.update(Message::DeleteFile("src/old.rs".to_string()));
1879
1880        assert_eq!(
1881            state.active_tab().pending_delete_file.as_deref(),
1882            Some("src/old.rs")
1883        );
1884        assert!(state.active_tab().context_menu.is_none());
1885    }
1886
1887    #[test]
1888    fn cancel_delete_file_clears_pending() {
1889        use crate::message::Message;
1890        let mut state = GitKraft::new();
1891        state.active_tab_mut().pending_delete_file = Some("src/old.rs".to_string());
1892
1893        let _ = state.update(Message::CancelDeleteFile);
1894
1895        assert!(state.active_tab().pending_delete_file.is_none());
1896    }
1897
1898    #[test]
1899    fn confirm_delete_file_no_repo_is_noop() {
1900        use crate::message::Message;
1901        let mut state = GitKraft::new();
1902        state.active_tab_mut().pending_delete_file = Some("src/old.rs".to_string());
1903        // No repo_path → should not set is_loading
1904
1905        let _ = state.update(Message::ConfirmDeleteFile);
1906
1907        assert!(!state.active_tab().is_loading);
1908    }
1909
1910    #[test]
1911    fn shift_arrow_down_extends_file_list_selection_when_files_loaded() {
1912        use crate::message::Message;
1913        let mut state = GitKraft::new();
1914        state.active_tab_mut().repo_path =
1915            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1916        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1917        state.active_tab_mut().selected_file_index = Some(0);
1918        state.active_tab_mut().anchor_file_index = Some(0);
1919        // keyboard_modifiers must have SHIFT set for range selection to trigger
1920        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1921
1922        let _ = state.update(Message::ShiftArrowDown);
1923
1924        assert_eq!(state.active_tab().selected_file_index, Some(1));
1925        // Range should now include both files
1926        assert!(state.active_tab().selected_commit_file_indices.contains(&0));
1927        assert!(state.active_tab().selected_commit_file_indices.contains(&1));
1928    }
1929
1930    #[test]
1931    fn shift_arrow_down_falls_through_to_commit_log_when_no_files() {
1932        use crate::message::Message;
1933        let mut state = GitKraft::new();
1934        state.active_tab_mut().commits = make_test_commits(5);
1935        state.active_tab_mut().selected_commit = Some(1);
1936        state.active_tab_mut().anchor_commit_index = Some(1);
1937        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1938        // no commit_files
1939
1940        let _ = state.update(Message::ShiftArrowDown);
1941
1942        assert_eq!(state.active_tab().selected_commit, Some(2));
1943        assert!(state.active_tab().selected_commits.contains(&1));
1944        assert!(state.active_tab().selected_commits.contains(&2));
1945    }
1946
1947    #[test]
1948    fn file_system_changed_triggers_full_refresh() {
1949        use crate::message::Message;
1950        let mut state = GitKraft::new();
1951        state.active_tab_mut().repo_path =
1952            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1953
1954        // FileSystemChanged should call refresh_active_tab() which returns
1955        // a non-none Task.  We verify by checking that is_loading is NOT set
1956        // synchronously (the task is async), but that no error is set either.
1957        let _task = state.update(Message::FileSystemChanged);
1958
1959        // With a repo_path set, the handler must have attempted a refresh
1960        // (it returns a Task, so is_loading is set by the task executor, not here).
1961        // What we CAN check: no error was set, and status is not "error".
1962        assert!(
1963            state.active_tab().error_message.is_none(),
1964            "FileSystemChanged must not set an error message"
1965        );
1966    }
1967
1968    // ── Tab duplication prevention tests ───────────────────────────────────
1969
1970    /// Helper: build a minimal `RepoPayload` (aka `RepoSnapshot`) for a given path.
1971    fn fake_payload(workdir: &str) -> crate::message::RepoPayload {
1972        gitkraft_core::RepoSnapshot {
1973            info: gitkraft_core::RepoInfo {
1974                path: std::path::PathBuf::from(format!("{workdir}/.git")),
1975                workdir: Some(std::path::PathBuf::from(workdir)),
1976                head_branch: Some("main".into()),
1977                is_bare: false,
1978                state: gitkraft_core::RepoState::Clean,
1979            },
1980            branches: Vec::new(),
1981            commits: Vec::new(),
1982            graph_rows: Vec::new(),
1983            unstaged: Vec::new(),
1984            staged: Vec::new(),
1985            stashes: Vec::new(),
1986            remotes: Vec::new(),
1987        }
1988    }
1989
1990    /// Helper: set up a tab as if a repo was fully loaded.
1991    fn setup_loaded_tab(tab: &mut RepoTab, path: &str) {
1992        tab.repo_path = Some(std::path::PathBuf::from(path));
1993        tab.repo_info = Some(gitkraft_core::RepoInfo {
1994            path: std::path::PathBuf::from(format!("{path}/.git")),
1995            workdir: Some(std::path::PathBuf::from(path)),
1996            head_branch: Some("main".into()),
1997            is_bare: false,
1998            state: gitkraft_core::RepoState::Clean,
1999        });
2000    }
2001
2002    #[test]
2003    fn open_repo_creates_new_tab_when_repo_already_open() {
2004        use crate::message::Message;
2005        let mut state = GitKraft::new();
2006        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2007
2008        assert_eq!(state.tabs.len(), 1);
2009        assert_eq!(state.active_tab, 0);
2010
2011        // Clicking "Open" should create a new tab when the current has a repo.
2012        let _task = state.update(Message::OpenRepo);
2013
2014        assert_eq!(state.tabs.len(), 2);
2015        assert_eq!(state.active_tab, 1);
2016        // The new tab should be loading (folder picker opening).
2017        assert!(state.tabs[1].is_loading);
2018        // The original tab should be untouched.
2019        assert_eq!(
2020            state.tabs[0].repo_path.as_deref(),
2021            Some(std::path::Path::new("/home/user/repo-a"))
2022        );
2023    }
2024
2025    #[test]
2026    fn open_repo_reuses_empty_tab() {
2027        use crate::message::Message;
2028        let mut state = GitKraft::new();
2029        // Active tab is empty (no repo loaded).
2030        assert!(!state.active_tab().has_repo());
2031
2032        let _task = state.update(Message::OpenRepo);
2033
2034        // Should NOT create a new tab when the active tab is empty.
2035        assert_eq!(state.tabs.len(), 1);
2036        assert_eq!(state.active_tab, 0);
2037        assert!(state.tabs[0].is_loading);
2038    }
2039
2040    #[test]
2041    fn repo_selected_deduplicates_already_open_repo() {
2042        use crate::message::Message;
2043        let mut state = GitKraft::new();
2044        // Tab 0: fully loaded repo-a
2045        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2046        // Tab 1: empty (simulates the new tab created by OpenRepo)
2047        state.tabs.push(RepoTab::new_empty());
2048        state.active_tab = 1;
2049
2050        // User selects a folder that matches the already-open repo.
2051        let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
2052            "/home/user/repo-a",
2053        ))));
2054
2055        // Should switch to the existing tab and remove the empty one.
2056        assert_eq!(state.tabs.len(), 1);
2057        assert_eq!(state.active_tab, 0);
2058        assert_eq!(
2059            state.tabs[0].repo_path.as_deref(),
2060            Some(std::path::Path::new("/home/user/repo-a"))
2061        );
2062    }
2063
2064    #[test]
2065    fn repo_selected_opens_new_repo_in_empty_tab() {
2066        use crate::message::Message;
2067        let mut state = GitKraft::new();
2068        // Tab 0: fully loaded repo-a
2069        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2070        // Tab 1: empty (simulates the new tab created by OpenRepo)
2071        state.tabs.push(RepoTab::new_empty());
2072        state.active_tab = 1;
2073
2074        // User selects a DIFFERENT repo.
2075        let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
2076            "/home/user/repo-b",
2077        ))));
2078
2079        // Should keep both tabs; the empty tab is now loading repo-b.
2080        assert_eq!(state.tabs.len(), 2);
2081        assert_eq!(state.active_tab, 1);
2082        assert!(state.tabs[1]
2083            .status_message
2084            .as_deref()
2085            .unwrap_or("")
2086            .contains("repo-b"));
2087    }
2088
2089    #[test]
2090    fn repo_selected_cancel_removes_empty_tab() {
2091        use crate::message::Message;
2092        let mut state = GitKraft::new();
2093        // Tab 0: fully loaded repo-a
2094        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2095        // Tab 1: empty (created by OpenRepo, waiting for folder picker)
2096        state.tabs.push(RepoTab::new_empty());
2097        state.active_tab = 1;
2098
2099        // User cancels the folder picker.
2100        let _task = state.update(Message::RepoSelected(None));
2101
2102        // The empty tab should be removed; switch back to tab 0.
2103        assert_eq!(state.tabs.len(), 1);
2104        assert_eq!(state.active_tab, 0);
2105        assert_eq!(
2106            state.tabs[0].repo_path.as_deref(),
2107            Some(std::path::Path::new("/home/user/repo-a"))
2108        );
2109    }
2110
2111    #[test]
2112    fn repo_selected_cancel_keeps_tab_if_only_one() {
2113        use crate::message::Message;
2114        let mut state = GitKraft::new();
2115        // Single empty tab — shouldn't be removed on cancel.
2116        assert_eq!(state.tabs.len(), 1);
2117        assert!(!state.active_tab().has_repo());
2118
2119        let _task = state.update(Message::RepoSelected(None));
2120
2121        assert_eq!(state.tabs.len(), 1);
2122        assert!(!state.active_tab().is_loading);
2123    }
2124
2125    #[test]
2126    fn open_recent_repo_deduplicates() {
2127        use crate::message::Message;
2128        let mut state = GitKraft::new();
2129        // Tab 0: fully loaded repo-a
2130        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2131        // Tab 1: empty tab
2132        state.tabs.push(RepoTab::new_empty());
2133        state.active_tab = 1;
2134
2135        // Opening a recent repo that's already open should switch to it.
2136        let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2137            "/home/user/repo-a",
2138        )));
2139
2140        assert_eq!(state.active_tab, 0);
2141    }
2142
2143    #[test]
2144    fn open_recent_repo_creates_new_tab_when_current_has_repo() {
2145        use crate::message::Message;
2146        let mut state = GitKraft::new();
2147        // Tab 0: fully loaded repo-a
2148        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2149
2150        // Opening a different recent repo should create a new tab.
2151        let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2152            "/home/user/repo-b",
2153        )));
2154
2155        assert_eq!(state.tabs.len(), 2);
2156        assert_eq!(state.active_tab, 1);
2157        assert!(state.tabs[1].is_loading);
2158    }
2159
2160    #[test]
2161    fn open_recent_repo_uses_empty_tab() {
2162        use crate::message::Message;
2163        let mut state = GitKraft::new();
2164        // Active tab is empty.
2165        assert!(!state.active_tab().has_repo());
2166
2167        let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2168            "/home/user/repo-b",
2169        )));
2170
2171        // Should NOT create a new tab.
2172        assert_eq!(state.tabs.len(), 1);
2173        assert_eq!(state.active_tab, 0);
2174        assert!(state.tabs[0].is_loading);
2175    }
2176
2177    // ── Refresh race-condition tests ──────────────────────────────────────
2178
2179    #[test]
2180    fn repo_refreshed_targets_correct_tab_after_tab_switch() {
2181        use crate::message::Message;
2182        let mut state = GitKraft::new();
2183        // Tab 0: repo-a fully loaded
2184        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2185        // Tab 1: empty tab (user clicked "+")
2186        state.tabs.push(RepoTab::new_empty());
2187        state.active_tab = 1; // user switched to new empty tab
2188
2189        // Simulate: a RepoRefreshed result arrives for repo-a while tab 1 is active.
2190        let payload = fake_payload("/home/user/repo-a");
2191        let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2192
2193        // The payload must land in tab 0 (which owns repo-a), NOT tab 1.
2194        assert!(
2195            state.tabs[0].repo_info.is_some(),
2196            "tab 0 should still have repo info after refresh"
2197        );
2198        assert_eq!(
2199            state.tabs[0].current_branch.as_deref(),
2200            Some("main"),
2201            "tab 0 should have updated branch from payload"
2202        );
2203        // Tab 1 must remain empty.
2204        assert!(
2205            state.tabs[1].repo_info.is_none(),
2206            "tab 1 (empty) must NOT receive the refresh payload"
2207        );
2208        assert!(
2209            state.tabs[1].repo_path.is_none(),
2210            "tab 1 should still have no repo path"
2211        );
2212    }
2213
2214    #[test]
2215    fn repo_refreshed_targets_active_tab_for_new_open() {
2216        use crate::message::Message;
2217        let mut state = GitKraft::new();
2218        // Active tab is empty — user just opened a brand-new repo.
2219        // (RepoSelected set the status but the tab doesn't have repo_path yet
2220        // matching the payload, so it falls back to active tab.)
2221        assert_eq!(state.tabs.len(), 1);
2222        assert!(!state.active_tab().has_repo());
2223
2224        let payload = fake_payload("/home/user/new-repo");
2225        let _task = state.update(Message::RepoOpened(Ok(payload)));
2226
2227        // Should have applied to the active tab (the only tab).
2228        assert_eq!(
2229            state.tabs[0].repo_path.as_deref(),
2230            Some(std::path::Path::new("/home/user/new-repo"))
2231        );
2232        assert!(state.tabs[0].repo_info.is_some());
2233    }
2234
2235    #[test]
2236    fn repo_refreshed_does_not_duplicate_into_new_tab() {
2237        use crate::message::Message;
2238        let mut state = GitKraft::new();
2239        // Tab 0: repo-a loaded
2240        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2241
2242        // User clicks "+" creating an empty tab 1, then switches to it.
2243        let _task = state.update(Message::NewTab);
2244        assert_eq!(state.tabs.len(), 2);
2245        assert_eq!(state.active_tab, 1);
2246
2247        // A refresh result for repo-a arrives (was triggered before the tab switch).
2248        let payload = fake_payload("/home/user/repo-a");
2249        let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2250
2251        // Tab 1 must remain empty — the payload should go to tab 0.
2252        assert!(
2253            state.tabs[1].repo_path.is_none(),
2254            "new empty tab must not receive repo-a refresh"
2255        );
2256        assert!(
2257            state.tabs[1].repo_info.is_none(),
2258            "new empty tab must not have repo_info"
2259        );
2260        // Tab 0 must have the refreshed data.
2261        assert_eq!(
2262            state.tabs[0].repo_path.as_deref(),
2263            Some(std::path::Path::new("/home/user/repo-a"))
2264        );
2265    }
2266
2267    #[test]
2268    fn git_operation_result_targets_correct_tab() {
2269        use crate::message::Message;
2270        let mut state = GitKraft::new();
2271        // Tab 0: repo-a
2272        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2273        // Tab 1: repo-b
2274        state.tabs.push(RepoTab::new_empty());
2275        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2276        state.active_tab = 1;
2277
2278        // A git operation result arrives for repo-a (e.g. push completed)
2279        // while user is on tab 1.
2280        let payload = fake_payload("/home/user/repo-a");
2281        let _task = state.update(Message::GitOperationResult(Ok(payload)));
2282
2283        // Payload should land in tab 0 (repo-a), not tab 1.
2284        assert_eq!(state.tabs[0].current_branch.as_deref(), Some("main"));
2285        // Tab 1's data should remain unchanged (still repo-b).
2286        assert_eq!(
2287            state.tabs[1].repo_path.as_deref(),
2288            Some(std::path::Path::new("/home/user/repo-b"))
2289        );
2290    }
2291
2292    #[test]
2293    fn multiple_new_tabs_dont_get_polluted_by_refresh() {
2294        use crate::message::Message;
2295        let mut state = GitKraft::new();
2296        // Tab 0: repo-a loaded
2297        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2298
2299        // User creates multiple new tabs.
2300        let _task = state.update(Message::NewTab);
2301        let _task = state.update(Message::NewTab);
2302        assert_eq!(state.tabs.len(), 3);
2303        assert_eq!(state.active_tab, 2);
2304
2305        // Refresh arrives for repo-a.
2306        let payload = fake_payload("/home/user/repo-a");
2307        let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2308
2309        // Only tab 0 should be affected.
2310        assert!(state.tabs[0].repo_info.is_some());
2311        assert!(state.tabs[1].repo_info.is_none());
2312        assert!(state.tabs[2].repo_info.is_none());
2313        assert!(state.tabs[1].repo_path.is_none());
2314        assert!(state.tabs[2].repo_path.is_none());
2315    }
2316
2317    #[test]
2318    fn repo_selected_dedup_adjusts_index_when_existing_is_after_active() {
2319        use crate::message::Message;
2320        let mut state = GitKraft::new();
2321        // Tab 0: empty (newly created by OpenRepo)
2322        // Tab 1: repo-a loaded
2323        state.tabs.push(RepoTab::new_empty());
2324        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-a");
2325        state.active_tab = 0;
2326
2327        // User selects the same folder as repo-a.
2328        let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
2329            "/home/user/repo-a",
2330        ))));
2331
2332        // Empty tab 0 should be removed; we should now be on tab 0 (formerly tab 1).
2333        assert_eq!(state.tabs.len(), 1);
2334        assert_eq!(state.active_tab, 0);
2335        assert_eq!(
2336            state.tabs[0].repo_path.as_deref(),
2337            Some(std::path::Path::new("/home/user/repo-a"))
2338        );
2339    }
2340
2341    // ── Multi-selection preservation across refresh ───────────────────────
2342
2343    #[test]
2344    fn apply_payload_preserves_multi_selection_by_oid() {
2345        let mut tab = RepoTab::new_empty();
2346        tab.commits = make_test_commits(5);
2347        // Simulate user selecting commits 1..=3 with Shift+click.
2348        tab.anchor_commit_index = Some(1);
2349        tab.selected_commits = vec![1, 2, 3];
2350        tab.selected_commit = Some(3);
2351        tab.selected_commit_oid = Some(tab.commits[3].oid.clone());
2352
2353        // Build a payload with the same commits (simulating a background refresh).
2354        let mut payload = fake_payload("/tmp/repo");
2355        payload.commits = make_test_commits(5);
2356
2357        tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2358
2359        // The multi-selection must survive the refresh.
2360        assert_eq!(tab.anchor_commit_index, Some(1));
2361        assert_eq!(tab.selected_commits, vec![1, 2, 3]);
2362        assert_eq!(tab.selected_commit, Some(3));
2363    }
2364
2365    #[test]
2366    fn apply_payload_preserves_anchor_even_without_range() {
2367        let mut tab = RepoTab::new_empty();
2368        tab.commits = make_test_commits(5);
2369        tab.anchor_commit_index = Some(2);
2370        tab.selected_commit = Some(2);
2371        tab.selected_commit_oid = Some(tab.commits[2].oid.clone());
2372        // No range selection — just a single click with anchor set.
2373
2374        let mut payload = fake_payload("/tmp/repo");
2375        payload.commits = make_test_commits(5);
2376
2377        tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2378
2379        assert_eq!(tab.anchor_commit_index, Some(2));
2380        assert_eq!(tab.selected_commit, Some(2));
2381    }
2382
2383    #[test]
2384    fn apply_payload_clears_selection_when_commits_disappear() {
2385        let mut tab = RepoTab::new_empty();
2386        tab.commits = make_test_commits(5);
2387        tab.anchor_commit_index = Some(2);
2388        tab.selected_commits = vec![2, 3, 4];
2389        tab.selected_commit = Some(4);
2390        tab.selected_commit_oid = Some(tab.commits[4].oid.clone());
2391
2392        // Payload has completely different commits (e.g. force-push).
2393        let mut payload = fake_payload("/tmp/repo");
2394        payload.commits = (0..3)
2395            .map(|i| gitkraft_core::CommitInfo {
2396                oid: format!("new_oid_{i}"),
2397                short_oid: format!("new_{i}"),
2398                summary: format!("new commit {i}"),
2399                message: String::new(),
2400                author_name: "Author".into(),
2401                author_email: "a@b.c".into(),
2402                time: Default::default(),
2403                parent_ids: Vec::new(),
2404                refs: Vec::new(),
2405            })
2406            .collect();
2407
2408        tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2409
2410        // All old OIDs are gone — selection should be cleared.
2411        assert!(tab.selected_commits.is_empty());
2412        assert!(tab.anchor_commit_index.is_none());
2413        assert!(tab.selected_commit.is_none());
2414    }
2415
2416    // ── File multi-selection preservation across refresh ─────────────────
2417
2418    #[test]
2419    fn apply_payload_preserves_file_selection_when_commit_survives() {
2420        let mut tab = RepoTab::new_empty();
2421        tab.commits = make_test_commits(3);
2422        tab.selected_commit = Some(1);
2423        tab.selected_commit_oid = Some(tab.commits[1].oid.clone());
2424        tab.commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
2425
2426        // Simulate user Shift+clicking files 0..=2.
2427        tab.anchor_file_index = Some(0);
2428        tab.selected_commit_file_indices = vec![0, 1, 2];
2429        tab.multi_file_diffs = vec![gitkraft_core::DiffInfo {
2430            old_file: String::new(),
2431            new_file: "a.rs".into(),
2432            status: gitkraft_core::FileStatus::Modified,
2433            hunks: vec![],
2434        }];
2435
2436        // Background refresh with the same commits.
2437        let mut payload = fake_payload("/tmp/repo");
2438        payload.commits = make_test_commits(3);
2439
2440        tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2441
2442        // File selection must survive.
2443        assert_eq!(tab.anchor_file_index, Some(0));
2444        assert_eq!(tab.selected_commit_file_indices, vec![0, 1, 2]);
2445        assert!(
2446            !tab.multi_file_diffs.is_empty(),
2447            "multi_file_diffs must survive when commit is preserved"
2448        );
2449    }
2450
2451    #[test]
2452    fn apply_payload_clears_file_selection_when_commit_disappears() {
2453        let mut tab = RepoTab::new_empty();
2454        tab.commits = make_test_commits(3);
2455        tab.selected_commit = Some(1);
2456        tab.selected_commit_oid = Some(tab.commits[1].oid.clone());
2457        tab.commit_files = make_commit_files(&["a.rs", "b.rs"]);
2458        tab.anchor_file_index = Some(0);
2459        tab.selected_commit_file_indices = vec![0, 1];
2460        tab.multi_file_diffs = vec![gitkraft_core::DiffInfo {
2461            old_file: String::new(),
2462            new_file: "a.rs".into(),
2463            status: gitkraft_core::FileStatus::Modified,
2464            hunks: vec![],
2465        }];
2466
2467        // Payload with completely different commits (force-push).
2468        let mut payload = fake_payload("/tmp/repo");
2469        payload.commits = (0..2)
2470            .map(|i| gitkraft_core::CommitInfo {
2471                oid: format!("new_{i}"),
2472                short_oid: format!("n{i}"),
2473                summary: String::new(),
2474                message: String::new(),
2475                author_name: String::new(),
2476                author_email: String::new(),
2477                time: Default::default(),
2478                parent_ids: Vec::new(),
2479                refs: Vec::new(),
2480            })
2481            .collect();
2482
2483        tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2484
2485        // Commit is gone → file selection must be cleared.
2486        assert!(tab.anchor_file_index.is_none());
2487        assert!(tab.selected_commit_file_indices.is_empty());
2488        assert!(tab.multi_file_diffs.is_empty());
2489        assert!(tab.commit_files.is_empty());
2490    }
2491
2492    #[test]
2493    fn apply_payload_preserves_commit_range_diffs_when_commit_survives() {
2494        let mut tab = RepoTab::new_empty();
2495        tab.commits = make_test_commits(5);
2496        tab.selected_commit = Some(2);
2497        tab.selected_commit_oid = Some(tab.commits[2].oid.clone());
2498        tab.commit_range_diffs = vec![gitkraft_core::DiffInfo {
2499            old_file: String::new(),
2500            new_file: "x.rs".into(),
2501            status: gitkraft_core::FileStatus::Modified,
2502            hunks: vec![],
2503        }];
2504
2505        let mut payload = fake_payload("/tmp/repo");
2506        payload.commits = make_test_commits(5);
2507
2508        tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2509
2510        assert!(
2511            !tab.commit_range_diffs.is_empty(),
2512            "commit_range_diffs must survive when commit is preserved"
2513        );
2514    }
2515
2516    // ── PreviewFile / OpenFiles message tests ─────────────────────────
2517
2518    #[test]
2519    fn preview_file_loaded_sets_selected_diff() {
2520        use crate::message::Message;
2521        let mut state = GitKraft::new();
2522        state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
2523
2524        let content = "line 1\nline 2\nline 3".to_string();
2525        let _ = state.update(Message::FilePreviewLoaded(Ok((
2526            "test.rs".to_string(),
2527            content,
2528        ))));
2529
2530        let diff = state.active_tab().selected_diff.as_ref().unwrap();
2531        assert_eq!(diff.new_file, "test.rs");
2532        assert_eq!(diff.hunks.len(), 1);
2533        assert_eq!(diff.hunks[0].lines.len(), 3);
2534        assert_eq!(state.active_tab().diff_scroll_offset, 0.0);
2535    }
2536
2537    #[test]
2538    fn preview_file_loaded_error_sets_error_message() {
2539        use crate::message::Message;
2540        let mut state = GitKraft::new();
2541        state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
2542
2543        let _ = state.update(Message::FilePreviewLoaded(Err("No such file".to_string())));
2544
2545        assert!(state.active_tab().error_message.is_some());
2546        assert!(state
2547            .active_tab()
2548            .error_message
2549            .as_ref()
2550            .unwrap()
2551            .contains("No such file"));
2552    }
2553
2554    // ── CloseRepo ─────────────────────────────────────────────────────────
2555
2556    #[test]
2557    fn close_repo_single_tab_shows_welcome_screen() {
2558        // When only one tab is open, CloseRepo should empty the tab (show
2559        // welcome screen) rather than removing it — there must always be at
2560        // least one tab.
2561        use crate::message::Message;
2562        let mut state = GitKraft::new();
2563        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2564        assert_eq!(state.tabs.len(), 1);
2565
2566        let _ = state.update(Message::CloseRepo);
2567
2568        assert_eq!(state.tabs.len(), 1, "last tab must not be removed");
2569        assert_eq!(state.active_tab, 0);
2570        assert!(!state.active_tab().has_repo(), "tab should be empty");
2571    }
2572
2573    #[test]
2574    fn close_repo_with_multiple_tabs_removes_current_tab() {
2575        // The core of the reported bug: when the currently selected repo is
2576        // closed and there are other tabs open, the CURRENT tab should be
2577        // removed and the user should land on the remaining tab — NOT have the
2578        // current tab emptied in place (which left the other repo displaced).
2579        //
2580        // Before fix: CloseRepo replaced active tab with empty, keeping it
2581        //   selected, so the user saw a blank welcome screen while the other
2582        //   repo was still in the tab bar but not visible → perceived as
2583        //   "removes the other one and keeps the current one selected".
2584        // After fix: active tab is removed, user lands on the other repo.
2585        use crate::message::Message;
2586        let mut state = GitKraft::new();
2587        // Tab 0: repo-a (will be the active/selected tab)
2588        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2589        // Tab 1: repo-b
2590        state.tabs.push(RepoTab::new_empty());
2591        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2592        // repo-a (tab 0) is the currently selected tab
2593        state.active_tab = 0;
2594
2595        let _ = state.update(Message::CloseRepo);
2596
2597        // The current tab (repo-a) must be gone; repo-b must be the only
2598        // remaining tab and must be active.
2599        assert_eq!(state.tabs.len(), 1, "current tab must be removed");
2600        assert_eq!(state.active_tab, 0);
2601        assert_eq!(
2602            state.active_tab().repo_path.as_deref(),
2603            Some(std::path::Path::new("/home/user/repo-b")),
2604            "repo-b should be the surviving tab"
2605        );
2606    }
2607
2608    #[test]
2609    fn close_repo_second_tab_active_removes_it_and_keeps_first() {
2610        // Same scenario but with the SECOND tab as the active (selected) one —
2611        // the first tab must survive and become the new active tab.
2612        use crate::message::Message;
2613        let mut state = GitKraft::new();
2614        // Tab 0: repo-a
2615        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2616        // Tab 1: repo-b (active)
2617        state.tabs.push(RepoTab::new_empty());
2618        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2619        state.active_tab = 1; // repo-b is the currently selected tab
2620
2621        let _ = state.update(Message::CloseRepo);
2622
2623        assert_eq!(state.tabs.len(), 1);
2624        assert_eq!(state.active_tab, 0);
2625        assert_eq!(
2626            state.active_tab().repo_path.as_deref(),
2627            Some(std::path::Path::new("/home/user/repo-a")),
2628            "repo-a should be the surviving tab"
2629        );
2630    }
2631
2632    // ── CloseTab ──────────────────────────────────────────────────────────
2633
2634    #[test]
2635    fn close_tab_active_tab_first_position() {
2636        // active_tab=0, close tab 0 → tab 1 (repo-b) becomes active at index 0.
2637        use crate::message::Message;
2638        let mut state = GitKraft::new();
2639        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2640        state.tabs.push(RepoTab::new_empty());
2641        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2642        state.active_tab = 0;
2643
2644        let _ = state.update(Message::CloseTab(0));
2645
2646        assert_eq!(state.tabs.len(), 1);
2647        assert_eq!(state.active_tab, 0);
2648        assert_eq!(
2649            state.active_tab().repo_path.as_deref(),
2650            Some(std::path::Path::new("/home/user/repo-b"))
2651        );
2652    }
2653
2654    #[test]
2655    fn close_tab_active_tab_second_position() {
2656        // active_tab=1, close tab 1 → tab 0 (repo-a) becomes active.
2657        use crate::message::Message;
2658        let mut state = GitKraft::new();
2659        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2660        state.tabs.push(RepoTab::new_empty());
2661        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2662        state.active_tab = 1;
2663
2664        let _ = state.update(Message::CloseTab(1));
2665
2666        assert_eq!(state.tabs.len(), 1);
2667        assert_eq!(state.active_tab, 0);
2668        assert_eq!(
2669            state.active_tab().repo_path.as_deref(),
2670            Some(std::path::Path::new("/home/user/repo-a"))
2671        );
2672    }
2673
2674    #[test]
2675    fn close_tab_non_active_tab_before_active() {
2676        // active_tab=1, close tab 0 → repo-b stays active but shifts to index 0.
2677        use crate::message::Message;
2678        let mut state = GitKraft::new();
2679        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2680        state.tabs.push(RepoTab::new_empty());
2681        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2682        state.active_tab = 1;
2683
2684        let _ = state.update(Message::CloseTab(0));
2685
2686        assert_eq!(state.tabs.len(), 1);
2687        assert_eq!(state.active_tab, 0, "active_tab must shift down by 1");
2688        assert_eq!(
2689            state.active_tab().repo_path.as_deref(),
2690            Some(std::path::Path::new("/home/user/repo-b")),
2691            "repo-b must remain active"
2692        );
2693    }
2694
2695    #[test]
2696    fn close_tab_non_active_tab_after_active() {
2697        // active_tab=0, close tab 1 → repo-a stays active at index 0.
2698        use crate::message::Message;
2699        let mut state = GitKraft::new();
2700        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2701        state.tabs.push(RepoTab::new_empty());
2702        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2703        state.active_tab = 0;
2704
2705        let _ = state.update(Message::CloseTab(1));
2706
2707        assert_eq!(state.tabs.len(), 1);
2708        assert_eq!(state.active_tab, 0, "active_tab must remain unchanged");
2709        assert_eq!(
2710            state.active_tab().repo_path.as_deref(),
2711            Some(std::path::Path::new("/home/user/repo-a")),
2712            "repo-a must remain active"
2713        );
2714    }
2715
2716    #[test]
2717    fn close_tab_last_remaining_tab_is_noop() {
2718        // Can't close the last tab — CloseTab must be a no-op.
2719        use crate::message::Message;
2720        let mut state = GitKraft::new();
2721        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2722        assert_eq!(state.tabs.len(), 1);
2723
2724        let _ = state.update(Message::CloseTab(0));
2725
2726        assert_eq!(state.tabs.len(), 1, "last tab must not be removed");
2727        assert!(
2728            state.active_tab().has_repo(),
2729            "tab content must be unchanged"
2730        );
2731    }
2732
2733    #[test]
2734    fn close_tab_out_of_bounds_index_is_noop() {
2735        // Closing a tab index that doesn't exist must be silently ignored.
2736        use crate::message::Message;
2737        let mut state = GitKraft::new();
2738        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2739        state.tabs.push(RepoTab::new_empty());
2740        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2741        assert_eq!(state.tabs.len(), 2);
2742
2743        let _ = state.update(Message::CloseTab(99));
2744
2745        assert_eq!(state.tabs.len(), 2, "no tab should be removed");
2746    }
2747
2748    // ── CloseTab — three-tab scenarios ────────────────────────────────────
2749
2750    #[test]
2751    fn close_tab_three_tabs_close_first_active() {
2752        // [repo-a*(0), repo-b(1), repo-c(2)] close 0
2753        // → [repo-b(0), repo-c(1)], active stays 0 (repo-b).
2754        use crate::message::Message;
2755        let mut state = GitKraft::new();
2756        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2757        state.tabs.push(RepoTab::new_empty());
2758        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2759        state.tabs.push(RepoTab::new_empty());
2760        setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
2761        state.active_tab = 0;
2762
2763        let _ = state.update(Message::CloseTab(0));
2764
2765        assert_eq!(state.tabs.len(), 2);
2766        assert_eq!(state.active_tab, 0);
2767        assert_eq!(
2768            state.active_tab().repo_path.as_deref(),
2769            Some(std::path::Path::new("/home/user/repo-b"))
2770        );
2771    }
2772
2773    #[test]
2774    fn close_tab_three_tabs_close_active_middle() {
2775        // [repo-a(0), repo-b*(1), repo-c(2)] close 1
2776        // → [repo-a(0), repo-c(1)], active_tab stays 1 (repo-c slides in).
2777        use crate::message::Message;
2778        let mut state = GitKraft::new();
2779        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2780        state.tabs.push(RepoTab::new_empty());
2781        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2782        state.tabs.push(RepoTab::new_empty());
2783        setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
2784        state.active_tab = 1;
2785
2786        let _ = state.update(Message::CloseTab(1));
2787
2788        assert_eq!(state.tabs.len(), 2);
2789        assert_eq!(
2790            state.active_tab, 1,
2791            "active_tab must stay at 1 (repo-c slides in)"
2792        );
2793        assert_eq!(
2794            state.active_tab().repo_path.as_deref(),
2795            Some(std::path::Path::new("/home/user/repo-c"))
2796        );
2797    }
2798
2799    #[test]
2800    fn close_tab_three_tabs_close_active_last() {
2801        // [repo-a(0), repo-b(1), repo-c*(2)] close 2
2802        // → [repo-a(0), repo-b(1)], active_tab becomes 1 (clamped).
2803        use crate::message::Message;
2804        let mut state = GitKraft::new();
2805        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2806        state.tabs.push(RepoTab::new_empty());
2807        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2808        state.tabs.push(RepoTab::new_empty());
2809        setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
2810        state.active_tab = 2;
2811
2812        let _ = state.update(Message::CloseTab(2));
2813
2814        assert_eq!(state.tabs.len(), 2);
2815        assert_eq!(
2816            state.active_tab, 1,
2817            "active_tab must be clamped to last index"
2818        );
2819        assert_eq!(
2820            state.active_tab().repo_path.as_deref(),
2821            Some(std::path::Path::new("/home/user/repo-b"))
2822        );
2823    }
2824
2825    #[test]
2826    fn close_tab_three_tabs_close_first_when_middle_active() {
2827        // [repo-a(0), repo-b*(1), repo-c(2)] close 0
2828        // → [repo-b(0), repo-c(1)], active_tab shifts from 1 to 0.
2829        use crate::message::Message;
2830        let mut state = GitKraft::new();
2831        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2832        state.tabs.push(RepoTab::new_empty());
2833        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2834        state.tabs.push(RepoTab::new_empty());
2835        setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
2836        state.active_tab = 1;
2837
2838        let _ = state.update(Message::CloseTab(0));
2839
2840        assert_eq!(state.tabs.len(), 2);
2841        assert_eq!(
2842            state.active_tab, 0,
2843            "active_tab must decrement when a tab before it is removed"
2844        );
2845        assert_eq!(
2846            state.active_tab().repo_path.as_deref(),
2847            Some(std::path::Path::new("/home/user/repo-b")),
2848            "repo-b must remain active"
2849        );
2850    }
2851
2852    #[test]
2853    fn close_tab_three_tabs_close_last_when_middle_active() {
2854        // [repo-a(0), repo-b*(1), repo-c(2)] close 2
2855        // → [repo-a(0), repo-b(1)], active_tab stays at 1.
2856        use crate::message::Message;
2857        let mut state = GitKraft::new();
2858        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2859        state.tabs.push(RepoTab::new_empty());
2860        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2861        state.tabs.push(RepoTab::new_empty());
2862        setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
2863        state.active_tab = 1;
2864
2865        let _ = state.update(Message::CloseTab(2));
2866
2867        assert_eq!(state.tabs.len(), 2);
2868        assert_eq!(
2869            state.active_tab, 1,
2870            "active_tab must not change when a tab after it is removed"
2871        );
2872        assert_eq!(
2873            state.active_tab().repo_path.as_deref(),
2874            Some(std::path::Path::new("/home/user/repo-b")),
2875            "repo-b must remain active"
2876        );
2877    }
2878
2879    // ── CloseRepo — three-tab scenarios ───────────────────────────────────
2880
2881    #[test]
2882    fn close_repo_three_tabs_active_in_middle() {
2883        // [repo-a(0), repo-b*(1), repo-c(2)] close active
2884        // → [repo-a(0), repo-c(1)], active_tab stays 1 (repo-c slides in).
2885        use crate::message::Message;
2886        let mut state = GitKraft::new();
2887        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2888        state.tabs.push(RepoTab::new_empty());
2889        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2890        state.tabs.push(RepoTab::new_empty());
2891        setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
2892        state.active_tab = 1;
2893
2894        let _ = state.update(Message::CloseRepo);
2895
2896        assert_eq!(state.tabs.len(), 2);
2897        assert_eq!(state.active_tab, 1);
2898        assert_eq!(
2899            state.active_tab().repo_path.as_deref(),
2900            Some(std::path::Path::new("/home/user/repo-c"))
2901        );
2902    }
2903
2904    #[test]
2905    fn close_repo_three_tabs_active_at_end() {
2906        // [repo-a(0), repo-b(1), repo-c*(2)] close active
2907        // → [repo-a(0), repo-b(1)], active_tab clamped to 1.
2908        use crate::message::Message;
2909        let mut state = GitKraft::new();
2910        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2911        state.tabs.push(RepoTab::new_empty());
2912        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2913        state.tabs.push(RepoTab::new_empty());
2914        setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
2915        state.active_tab = 2;
2916
2917        let _ = state.update(Message::CloseRepo);
2918
2919        assert_eq!(state.tabs.len(), 2);
2920        assert_eq!(state.active_tab, 1, "active_tab clamped to last index");
2921        assert_eq!(
2922            state.active_tab().repo_path.as_deref(),
2923            Some(std::path::Path::new("/home/user/repo-b"))
2924        );
2925    }
2926
2927    // ── NewTab ────────────────────────────────────────────────────────────
2928
2929    #[test]
2930    fn new_tab_appends_empty_tab_and_makes_it_active() {
2931        use crate::message::Message;
2932        let mut state = GitKraft::new();
2933        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2934        assert_eq!(state.tabs.len(), 1);
2935
2936        let _ = state.update(Message::NewTab);
2937
2938        assert_eq!(state.tabs.len(), 2, "a new empty tab must be appended");
2939        assert_eq!(state.active_tab, 1, "new tab must become active");
2940        assert!(!state.active_tab().has_repo(), "new tab must be empty");
2941        // The original repo tab must be unaffected.
2942        assert!(state.tabs[0].has_repo());
2943    }
2944
2945    #[test]
2946    fn new_tab_with_multiple_existing_tabs() {
2947        use crate::message::Message;
2948        let mut state = GitKraft::new();
2949        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2950        state.tabs.push(RepoTab::new_empty());
2951        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2952        state.active_tab = 0;
2953
2954        let _ = state.update(Message::NewTab);
2955
2956        assert_eq!(state.tabs.len(), 3);
2957        assert_eq!(state.active_tab, 2);
2958        assert!(!state.active_tab().has_repo());
2959    }
2960
2961    // ── SwitchTab ─────────────────────────────────────────────────────────
2962
2963    #[test]
2964    fn switch_tab_to_valid_index() {
2965        use crate::message::Message;
2966        let mut state = GitKraft::new();
2967        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2968        state.tabs.push(RepoTab::new_empty());
2969        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2970        state.active_tab = 0;
2971
2972        let _ = state.update(Message::SwitchTab(1));
2973
2974        assert_eq!(state.active_tab, 1);
2975        assert_eq!(
2976            state.active_tab().repo_path.as_deref(),
2977            Some(std::path::Path::new("/home/user/repo-b"))
2978        );
2979    }
2980
2981    #[test]
2982    fn switch_tab_to_same_index_is_noop() {
2983        use crate::message::Message;
2984        let mut state = GitKraft::new();
2985        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2986        state.tabs.push(RepoTab::new_empty());
2987        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2988        state.active_tab = 1;
2989
2990        let _ = state.update(Message::SwitchTab(1));
2991
2992        assert_eq!(state.active_tab, 1, "should remain on tab 1");
2993    }
2994
2995    #[test]
2996    fn switch_tab_out_of_bounds_is_noop() {
2997        // Firing SwitchTab with a too-large index must not panic or change state.
2998        use crate::message::Message;
2999        let mut state = GitKraft::new();
3000        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3001        state.tabs.push(RepoTab::new_empty());
3002        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3003        state.active_tab = 0;
3004
3005        let _ = state.update(Message::SwitchTab(99));
3006
3007        assert_eq!(state.active_tab, 0, "out-of-bounds switch must be ignored");
3008    }
3009}