Skip to main content

gitkraft_gui/
update.rs

1//! Top-level update function for the GitKraft application.
2//!
3//! Matches on each [`Message`] variant and delegates to the appropriate
4//! feature's update handler. Each feature handler receives `&mut GitKraft`
5//! and the message, and returns a `Task<Message>` for any follow-up async work.
6
7use std::collections::HashSet;
8
9use iced::Task;
10
11use crate::macros::StringErr;
12use crate::message::Message;
13use crate::state::GitKraft;
14
15impl GitKraft {
16    /// The single entry-point for all application updates. Iced calls this
17    /// whenever a [`Message`] is produced (by user interaction or an async
18    /// task completing).
19    pub fn update(&mut self, message: Message) -> Task<Message> {
20        match &message {
21            // ── Tabs ──────────────────────────────────────────────────────
22            Message::SwitchTab(index) => {
23                let index = *index;
24                if index < self.tabs.len() {
25                    self.active_tab = index;
26                }
27                Task::none()
28            }
29
30            Message::NewTab => {
31                self.tabs.push(crate::state::RepoTab::new_empty());
32                self.active_tab = self.tabs.len() - 1;
33                // Refresh recent repos so the welcome screen is up to date.
34                crate::features::repo::commands::load_recent_repos_async()
35            }
36
37            Message::CloseTab(index) => {
38                let index = *index;
39                if self.tabs.len() > 1 && index < self.tabs.len() {
40                    self.tabs.remove(index);
41                    // Adjust active_tab if needed.
42                    if self.active_tab >= self.tabs.len() {
43                        self.active_tab = self.tabs.len() - 1;
44                    } else if self.active_tab > index {
45                        self.active_tab -= 1;
46                    }
47                }
48                let (open_tabs, active) = self.session_state();
49                Task::batch([
50                    crate::features::repo::commands::save_session_async(open_tabs, active),
51                    crate::features::repo::commands::save_layout_async(self.current_layout()),
52                ])
53            }
54
55            // ── Repository ────────────────────────────────────────────────
56            Message::OpenRepo
57            | Message::InitRepo
58            | Message::RepoSelected(_)
59            | Message::RepoInitSelected(_)
60            | Message::RepoOpened(_)
61            | Message::RefreshRepo
62            | Message::RepoRefreshed(_)
63            | Message::OpenRecentRepo(_)
64            | Message::CloseRepo
65            | Message::RepoRecorded(_)
66            | Message::RepoRestoredAt(_, _)
67            | Message::MoreCommitsLoaded(_)
68            | Message::SettingsLoaded(_)
69            | Message::GitOperationResult(_) => {
70                crate::features::repo::update::update(self, message)
71            }
72
73            // ── Branches ──────────────────────────────────────────────────
74            Message::CheckoutBranch(_)
75            | Message::BranchCheckedOut(_)
76            | Message::CreateBranch
77            | Message::NewBranchNameChanged(_)
78            | Message::BranchCreated(_)
79            | Message::DeleteBranch(_)
80            | Message::BranchDeleted(_)
81            | Message::ToggleBranchCreate
82            | Message::ToggleLocalBranches
83            | Message::ToggleRemoteBranches => {
84                crate::features::branches::update::update(self, message)
85            }
86
87            // ── Commits ───────────────────────────────────────────────────
88            Message::SelectCommit(_)
89            | Message::CommitFileListLoaded(_)
90            | Message::SingleFileDiffLoaded(_)
91            | Message::DiffFileWithWorkingTree(_, _)
92            | Message::DiffWithWorkingTreeLoaded(_)
93            | Message::DiffMultiWithWorkingTree(_, _)
94            | Message::CheckoutFileAtCommit(_, _)
95            | Message::CheckoutMultiFilesAtCommit(_, _)
96            | Message::CommitRangeDiffLoaded(_) => {
97                // Both the commits and diff features care about SelectCommit.
98                // We delegate to the commits handler which also loads the diff.
99                crate::features::commits::update::update(self, message)
100            }
101
102            Message::CommitMessageChanged(_)
103            | Message::CreateCommit
104            | Message::CommitCreated(_) => crate::features::commits::update::update(self, message),
105
106            Message::CommitLogScrolled(abs_y, rel_y) => {
107                // Store the scroll offset so the view can virtualise rows.
108                self.active_tab_mut().commit_scroll_offset = *abs_y;
109
110                // Pagination: load more commits when the user nears the bottom.
111                const COMMITS_PAGE_SIZE: usize = 200;
112                const LOAD_TRIGGER_RELATIVE: f32 = 0.85;
113
114                let tab = self.active_tab();
115                if *rel_y >= LOAD_TRIGGER_RELATIVE
116                    && tab.has_more_commits
117                    && !tab.is_loading_more_commits
118                {
119                    if let Some(path) = tab.repo_path.clone() {
120                        let current = tab.commits.len();
121                        self.active_tab_mut().is_loading_more_commits = true;
122                        return crate::features::repo::commands::load_more_commits(
123                            path,
124                            current,
125                            COMMITS_PAGE_SIZE,
126                        );
127                    }
128                }
129                Task::none()
130            }
131
132            Message::DiffViewScrolled(abs_y) => {
133                self.active_tab_mut().diff_scroll_offset = *abs_y;
134                Task::none()
135            }
136
137            // ── Staging ───────────────────────────────────────────────────
138            Message::StageFile(_)
139            | Message::UnstageFile(_)
140            | Message::StageAll
141            | Message::UnstageAll
142            | Message::DiscardFile(_)
143            | Message::ConfirmDiscard(_)
144            | Message::CancelDiscard
145            | Message::StagingUpdated(_)
146            | Message::ToggleSelectUnstaged(_)
147            | Message::ToggleSelectStaged(_)
148            | Message::StageSelected
149            | Message::UnstageSelected
150            | Message::DiscardSelected
151            | Message::DiscardStagedFile(_) => {
152                crate::features::staging::update::update(self, message)
153            }
154
155            // ── Stash ───────────────────────────────────────────────
156            Message::StashSave
157            | Message::StashPop(_)
158            | Message::StashPopFirst
159            | Message::StashDrop(_)
160            | Message::StashUpdated(_)
161            | Message::StashMessageChanged(_)
162            | Message::StashApply(_)
163            | Message::ViewStashDiff(_)
164            | Message::StashDiffLoaded(_) => crate::features::stash::update::update(self, message),
165
166            // ── Remotes ───────────────────────────────────────────────────
167            Message::Fetch | Message::FetchCompleted(_) => {
168                crate::features::remotes::update::update(self, message)
169            }
170
171            // ── UI / misc ─────────────────────────────────────────────────
172            Message::LoadStagingFileDiff(_, _)
173            | Message::StagingFileDiffLoaded(_)
174            | Message::SelectDiffByIndex(_)
175            | Message::CommitMultiDiffLoaded(_) => {
176                crate::features::diff::update::update(self, message)
177            }
178
179            Message::ModifiersChanged(mods) => {
180                self.keyboard_modifiers = *mods;
181                Task::none()
182            }
183
184            Message::DismissError => {
185                self.active_tab_mut().error_message = None;
186                Task::none()
187            }
188
189            Message::ZoomIn => {
190                self.ui_scale = ((self.ui_scale + 0.1) * 10.0).round() / 10.0;
191                self.ui_scale = self.ui_scale.min(2.0);
192                crate::features::repo::commands::save_layout_async(self.current_layout())
193            }
194
195            Message::ZoomOut => {
196                self.ui_scale = ((self.ui_scale - 0.1) * 10.0).round() / 10.0;
197                self.ui_scale = self.ui_scale.max(0.5);
198                crate::features::repo::commands::save_layout_async(self.current_layout())
199            }
200
201            Message::ZoomReset => {
202                self.ui_scale = 1.0;
203                crate::features::repo::commands::save_layout_async(self.current_layout())
204            }
205
206            Message::AnimationTick => {
207                self.animation_tick = self.animation_tick.wrapping_add(1);
208                Task::none()
209            }
210
211            Message::ShiftArrowDown => {
212                // Priority 1: file list (if commit files are loaded and a file is selected).
213                let file_info: Option<(usize, usize)> = {
214                    let tab = self.active_tab();
215                    if !tab.commit_files.is_empty() {
216                        tab.selected_file_index
217                            .map(|cur| (cur, tab.commit_files.len()))
218                    } else {
219                        None
220                    }
221                };
222                if let Some((current, files_len)) = file_info {
223                    let new_idx = (current + 1).min(files_len.saturating_sub(1));
224                    if new_idx != current {
225                        return crate::features::diff::update::update(
226                            self,
227                            Message::SelectDiffByIndex(new_idx),
228                        );
229                    }
230                    return Task::none();
231                }
232                // Priority 2: commit log.
233                let commit_info: Option<(usize, usize)> = {
234                    let tab = self.active_tab();
235                    if !tab.commits.is_empty() {
236                        Some((tab.selected_commit.unwrap_or(0), tab.commits.len()))
237                    } else {
238                        None
239                    }
240                };
241                if let Some((current, commits_len)) = commit_info {
242                    let new_idx = (current + 1).min(commits_len.saturating_sub(1));
243                    if new_idx != current {
244                        return crate::features::commits::update::update(
245                            self,
246                            Message::SelectCommit(new_idx),
247                        );
248                    }
249                }
250                Task::none()
251            }
252
253            Message::ShiftArrowUp => {
254                // Priority 1: file list (if commit files are loaded and a file is selected).
255                let file_info: Option<(usize, usize)> = {
256                    let tab = self.active_tab();
257                    if !tab.commit_files.is_empty() {
258                        tab.selected_file_index
259                            .map(|cur| (cur, tab.commit_files.len()))
260                    } else {
261                        None
262                    }
263                };
264                if let Some((current, _files_len)) = file_info {
265                    let new_idx = current.saturating_sub(1);
266                    if new_idx != current {
267                        return crate::features::diff::update::update(
268                            self,
269                            Message::SelectDiffByIndex(new_idx),
270                        );
271                    }
272                    return Task::none();
273                }
274                // Priority 2: commit log.
275                let commit_info: Option<(usize, usize)> = {
276                    let tab = self.active_tab();
277                    if !tab.commits.is_empty() {
278                        Some((tab.selected_commit.unwrap_or(0), tab.commits.len()))
279                    } else {
280                        None
281                    }
282                };
283                if let Some((current, _commits_len)) = commit_info {
284                    let new_idx = current.saturating_sub(1);
285                    if new_idx != current {
286                        return crate::features::commits::update::update(
287                            self,
288                            Message::SelectCommit(new_idx),
289                        );
290                    }
291                }
292                Task::none()
293            }
294
295            Message::JumpToFirstCommit => {
296                let tab = self.active_tab_mut();
297                if !tab.commits.is_empty() {
298                    tab.selected_commit = Some(0);
299                    tab.anchor_commit_index = Some(0);
300                    tab.selected_commits.clear();
301                }
302                iced::widget::operation::scroll_to(
303                    crate::features::commits::view::commit_log_scroll_id(self.active_tab),
304                    iced::widget::operation::AbsoluteOffset { x: 0.0, y: 0.0 },
305                )
306            }
307
308            Message::JumpToLastCommit => {
309                let last = self.active_tab().commits.len().saturating_sub(1);
310                if !self.active_tab().commits.is_empty() {
311                    let tab = self.active_tab_mut();
312                    tab.selected_commit = Some(last);
313                    tab.anchor_commit_index = Some(last);
314                    tab.selected_commits.clear();
315                }
316                iced::widget::operation::scroll_to(
317                    crate::features::commits::view::commit_log_scroll_id(self.active_tab),
318                    iced::widget::operation::AbsoluteOffset {
319                        x: 0.0,
320                        y: f32::MAX,
321                    },
322                )
323            }
324
325            Message::ToggleCommitSelection => {
326                let index = match self.active_tab().selected_commit {
327                    Some(i) => i,
328                    None => return Task::none(),
329                };
330                let tab = self.active_tab_mut();
331                if tab.selected_commits.contains(&index) {
332                    tab.selected_commits.retain(|&i| i != index);
333                } else {
334                    tab.selected_commits.push(index);
335                    tab.selected_commits.sort_unstable();
336                }
337                Task::none()
338            }
339
340            Message::ToggleSidebar => {
341                self.sidebar_expanded = !self.sidebar_expanded;
342                crate::features::repo::commands::save_layout_async(self.current_layout())
343            }
344
345            Message::WindowResized(w, h) => {
346                let w = *w;
347                let h = *h;
348                self.window_width = w;
349                self.window_height = h;
350                Task::none()
351            }
352
353            Message::WindowMoved(x, y) => {
354                let x = *x;
355                let y = *y;
356                self.window_x = x;
357                self.window_y = y;
358                Task::none()
359            }
360
361            Message::ToggleAbout => {
362                self.show_about = !self.show_about;
363                Task::none()
364            }
365
366            Message::OpenUrl(url) => {
367                if let Err(e) = gitkraft_core::open_file_default(std::path::Path::new(url)) {
368                    self.active_tab_mut().error_message =
369                        Some(format!("Could not open {url}: {e}"));
370                }
371                Task::none()
372            }
373
374            Message::OpenSettingsFile => {
375                // Resolve the settings file path.
376                let path = match gitkraft_core::features::persistence::ops::settings_json_path() {
377                    Ok(p) => p,
378                    Err(e) => {
379                        let msg = format!("Cannot determine settings path: {e}");
380                        self.active_tab_mut().error_message = Some(msg);
381                        return Task::none();
382                    }
383                };
384
385                // Ensure the file exists so the editor can open it immediately.
386                if !path.exists() {
387                    let snap = gitkraft_core::features::persistence::ops::load_settings()
388                        .unwrap_or_default();
389                    if let Err(e) = gitkraft_core::features::persistence::ops::save_settings(&snap)
390                    {
391                        let msg = format!("Could not create settings file: {e}");
392                        self.active_tab_mut().error_message = Some(msg);
393                        return Task::none();
394                    }
395                }
396
397                let path_str = path.display().to_string();
398
399                // open_file_or_default tries the configured editor first, then
400                // falls back to the system default opener (xdg-open / open /
401                // start).  On macOS, GUI editors are activated via `open -a`
402                // so the existing window is brought to the front.
403                match self.editor.open_file_or_default(&path) {
404                    Ok(method) => {
405                        let msg = format!("Settings opened in {method} — {path_str}");
406                        self.active_tab_mut().status_message = Some(msg);
407                    }
408                    Err(e) => {
409                        // Opening failed entirely — show the path so the user
410                        // can find and open the file manually.
411                        let msg = format!("Could not open settings ({e}) — file is at: {path_str}");
412                        self.active_tab_mut().error_message = Some(msg);
413                    }
414                }
415                Task::none()
416            }
417
418            // ── Pane resize ───────────────────────────────────────────────
419            Message::PaneDragStart(target, _x) => {
420                self.dragging = Some(*target);
421                // Position is 0.0 because `on_press` doesn't provide coords.
422                // We set drag_initialized to false so the first `PaneDragMove`
423                // captures the real position instead of computing a bogus delta.
424                self.drag_initialized = false;
425                Task::none()
426            }
427
428            Message::PaneDragStartH(target, _y) => {
429                self.dragging_h = Some(*target);
430                self.drag_initialized_h = false;
431                Task::none()
432            }
433
434            Message::PaneDragMove(x, y) => {
435                use crate::state::{DragTarget, DragTargetH};
436
437                // Always record cursor position so context menus open at the pointer.
438                self.cursor_pos = iced::Point::new(*x, *y);
439
440                if let Some(target) = self.dragging {
441                    if !self.drag_initialized {
442                        // First move after press — just record the position.
443                        self.drag_start_x = *x;
444                        self.drag_initialized = true;
445                    } else {
446                        let dx = *x - self.drag_start_x;
447                        self.drag_start_x = *x;
448
449                        // Drag from the rendered (clamped) size so the divider
450                        // tracks the cursor in small windows.
451                        let d = crate::layout::LayoutDims::compute(
452                            self.window_width,
453                            self.window_height,
454                            self.sidebar_expanded,
455                            self.sidebar_width,
456                            self.commit_log_width,
457                            self.staging_height,
458                        );
459                        let (orig_sidebar, orig_commit) =
460                            (self.sidebar_width, self.commit_log_width);
461                        self.sidebar_width = d.sidebar_width.max(1.0);
462                        self.commit_log_width = d.commit_log_width.max(1.0);
463
464                        let dragged = target;
465                        match target {
466                            DragTarget::SidebarRight => {
467                                self.sidebar_width = (self.sidebar_width + dx).clamp(120.0, 500.0);
468                            }
469                            DragTarget::CommitLogRight => {
470                                self.commit_log_width =
471                                    (self.commit_log_width + dx).clamp(200.0, 1200.0);
472                            }
473                            DragTarget::DiffFileListRight => {
474                                self.diff_file_list_width =
475                                    (self.diff_file_list_width + dx).clamp(100.0, 400.0);
476                            }
477                            DragTarget::StagingUnstagedRight => {
478                                let staging_w = self.window_width;
479                                if staging_w > 0.0 {
480                                    let ratio_delta = dx / staging_w;
481                                    self.staging_unstaged_ratio = (self.staging_unstaged_ratio
482                                        + ratio_delta)
483                                        .clamp(0.15, 0.7);
484                                    let max_staged =
485                                        (1.0 - self.staging_unstaged_ratio - 0.1).max(0.1);
486                                    if self.staging_staged_ratio > max_staged {
487                                        self.staging_staged_ratio = max_staged;
488                                    }
489                                }
490                            }
491                            DragTarget::StagingStagedRight => {
492                                let staging_w = self.window_width;
493                                if staging_w > 0.0 {
494                                    let ratio_delta = dx / staging_w;
495                                    let max_staged =
496                                        (1.0 - self.staging_unstaged_ratio - 0.1).max(0.1);
497                                    self.staging_staged_ratio = (self.staging_staged_ratio
498                                        + ratio_delta)
499                                        .clamp(0.1, max_staged);
500                                }
501                            }
502                            DragTarget::CommitRefColumnRight => {
503                                self.commit_ref_width =
504                                    (self.commit_ref_width + dx).clamp(60.0, 250.0);
505                            }
506                            DragTarget::CommitGraphColumnRight => {
507                                self.commit_graph_width =
508                                    (self.commit_graph_width + dx).clamp(24.0, 300.0);
509                            }
510                        }
511                        // Only the dragged divider keeps its new value; others
512                        // retain their saved (unclamped) sizes.
513                        if dragged != DragTarget::SidebarRight {
514                            self.sidebar_width = orig_sidebar;
515                        }
516                        if dragged != DragTarget::CommitLogRight {
517                            self.commit_log_width = orig_commit;
518                        }
519                    }
520                }
521
522                if let Some(target_h) = self.dragging_h {
523                    if !self.drag_initialized_h {
524                        self.drag_start_y = *y;
525                        self.drag_initialized_h = true;
526                    } else {
527                        let dy = *y - self.drag_start_y;
528                        self.drag_start_y = *y;
529
530                        match target_h {
531                            DragTargetH::StagingTop => {
532                                // Dragging up → larger staging area (subtract dy).
533                                self.staging_height =
534                                    (self.staging_height - dy).clamp(100.0, 600.0);
535                            }
536                            DragTargetH::SidebarBranchesBottom => {
537                                // Convert pixel delta to ratio delta based on
538                                // the approximate sidebar height.
539                                let sidebar_h = self.window_height - 120.0;
540                                if sidebar_h > 0.0 {
541                                    let ratio_delta = dy / sidebar_h;
542                                    self.sidebar_branches_ratio =
543                                        (self.sidebar_branches_ratio + ratio_delta).clamp(0.1, 0.8);
544                                    // Ensure stash doesn't get squeezed out.
545                                    let max_stash =
546                                        (1.0 - self.sidebar_branches_ratio - 0.05).max(0.05);
547                                    if self.sidebar_stash_ratio > max_stash {
548                                        self.sidebar_stash_ratio = max_stash;
549                                    }
550                                }
551                            }
552                            DragTargetH::SidebarStashBottom => {
553                                let sidebar_h = self.window_height - 120.0;
554                                if sidebar_h > 0.0 {
555                                    let ratio_delta = dy / sidebar_h;
556                                    let max_stash =
557                                        (1.0 - self.sidebar_branches_ratio - 0.05).max(0.05);
558                                    self.sidebar_stash_ratio = (self.sidebar_stash_ratio
559                                        + ratio_delta)
560                                        .clamp(0.05, max_stash);
561                                }
562                            }
563                        }
564                    }
565                }
566
567                Task::none()
568            }
569
570            Message::PaneDragEnd => {
571                self.dragging = None;
572                self.dragging_h = None;
573                self.drag_initialized = false;
574                self.drag_initialized_h = false;
575                crate::features::repo::commands::save_layout_async(self.current_layout())
576            }
577
578            // ── Context menu lifecycle ────────────────────────────────────────────────
579            Message::OpenBranchContextMenu(name, local_index, is_current) => {
580                self.open_context_menu(crate::state::ContextMenu::Branch {
581                    name: name.clone(),
582                    is_current: *is_current,
583                    local_index: *local_index,
584                });
585                Task::none()
586            }
587
588            Message::OpenRemoteBranchContextMenu(name) => {
589                self.open_context_menu(crate::state::ContextMenu::RemoteBranch {
590                    name: name.clone(),
591                });
592                Task::none()
593            }
594
595            Message::OpenCommitFileContextMenu(oid, file_path) => {
596                self.open_context_menu(crate::state::ContextMenu::CommitFile {
597                    oid: oid.clone(),
598                    file_path: file_path.clone(),
599                });
600                Task::none()
601            }
602
603            Message::OpenStashContextMenu(index) => {
604                let index = *index;
605                self.open_context_menu(crate::state::ContextMenu::Stash { index });
606                Task::none()
607            }
608
609            Message::OpenUnstagedFileContextMenu(path) => {
610                self.open_context_menu(crate::state::ContextMenu::UnstagedFile {
611                    path: path.clone(),
612                });
613                Task::none()
614            }
615
616            Message::OpenStagedFileContextMenu(path) => {
617                self.open_context_menu(crate::state::ContextMenu::StagedFile {
618                    path: path.clone(),
619                });
620                Task::none()
621            }
622
623            Message::OpenCommitContextMenu(idx) => {
624                let oid = self.active_tab().commits.get(*idx).map(|c| c.oid.clone());
625                if let Some(oid) = oid {
626                    self.open_context_menu(crate::state::ContextMenu::Commit { index: *idx, oid });
627                }
628                Task::none()
629            }
630
631            Message::OpenSearchResultContextMenu(idx) => {
632                if let Some(commit) = self.search_results.get(*idx) {
633                    let oid = commit.oid.clone();
634                    self.open_context_menu(crate::state::ContextMenu::Commit { index: *idx, oid });
635                }
636                Task::none()
637            }
638
639            Message::CloseContextMenu => {
640                self.dismiss_menu();
641                Task::none()
642            }
643
644            // ── Inline branch rename ──────────────────────────────────────────────────
645            Message::BeginRenameBranch(name) => {
646                let tab = self.active_tab_mut();
647                tab.context_menu = None;
648                tab.rename_branch_input = name.clone();
649                tab.rename_branch_target = Some(name.clone());
650                Task::none()
651            }
652
653            Message::RenameBranchInputChanged(s) => {
654                self.active_tab_mut().rename_branch_input = s.clone();
655                Task::none()
656            }
657
658            Message::CancelRename => {
659                let tab = self.active_tab_mut();
660                tab.rename_branch_target = None;
661                tab.rename_branch_input.clear();
662                Task::none()
663            }
664
665            Message::ConfirmRenameBranch => {
666                let (original, new_name, path) = {
667                    let tab = self.active_tab();
668                    (
669                        tab.rename_branch_target.clone(),
670                        tab.rename_branch_input.trim().to_string(),
671                        tab.repo_path.clone(),
672                    )
673                };
674                if let (Some(orig), false) = (&original, new_name.is_empty()) {
675                    if *orig != new_name {
676                        if let Some(path) = path {
677                            let orig = orig.clone();
678                            {
679                                let tab = self.active_tab_mut();
680                                tab.rename_branch_target = None;
681                                tab.rename_branch_input.clear();
682                                tab.is_loading = true;
683                                tab.status_message =
684                                    Some(format!("Renaming '{orig}' → '{new_name}'…"));
685                            }
686                            return crate::features::repo::commands::rename_branch_async(
687                                path, orig, new_name,
688                            );
689                        }
690                    }
691                }
692                self.active_tab_mut().rename_branch_target = None;
693                Task::none()
694            }
695
696            // ── Branch context menu actions ───────────────────────────────────────────────
697            Message::PushBranch(name) => {
698                let name = name.clone();
699                let remote = self.active_tab().default_remote();
700                self.dismiss_menu();
701                with_repo!(
702                    self,
703                    loading,
704                    format!("Pushing '{name}' to {remote}…"),
705                    |path| crate::features::repo::commands::push_branch_async(path, name, remote)
706                )
707            }
708
709            Message::ForcePushBranch => {
710                let branch = self.active_tab().current_branch.clone().unwrap_or_default();
711                let remote = self.active_tab().default_remote();
712                with_repo!(
713                    self,
714                    loading,
715                    format!("Force pushing '{branch}' to {remote}…"),
716                    |path| crate::features::repo::commands::force_push_branch_async(
717                        path, branch, remote
718                    )
719                )
720            }
721
722            Message::PullBranch(_name) => {
723                let remote = self.active_tab().default_remote();
724                self.dismiss_menu();
725                with_repo!(
726                    self,
727                    loading,
728                    format!("Pulling from {remote} (rebase)…"),
729                    |path| crate::features::repo::commands::pull_rebase_async(path, remote)
730                )
731            }
732
733            Message::RebaseOnto(target) => {
734                let target = target.clone();
735                self.dismiss_menu();
736                with_repo!(
737                    self,
738                    loading,
739                    format!("Rebasing onto '{target}'…"),
740                    |path| crate::features::repo::commands::rebase_onto_async(path, target)
741                )
742            }
743
744            Message::MergeBranch(name) => {
745                let name = name.clone();
746                self.dismiss_menu();
747                with_repo!(
748                    self,
749                    loading,
750                    format!("Merging '{name}' into current branch…"),
751                    |path| crate::features::repo::commands::merge_branch_async(path, name)
752                )
753            }
754
755            Message::CheckoutRemoteBranch(name) => {
756                let name = name.clone();
757                self.dismiss_menu();
758                with_repo!(self, loading, format!("Checking out '{name}'…"), |path| {
759                    crate::features::repo::commands::checkout_remote_branch_async(path, name)
760                })
761            }
762
763            Message::DeleteRemoteBranch(name) => {
764                let name = name.clone();
765                self.dismiss_menu();
766                with_repo!(
767                    self,
768                    loading,
769                    format!("Deleting remote branch '{name}'…"),
770                    |path| crate::features::repo::commands::delete_remote_branch_async(path, name)
771                )
772            }
773
774            Message::BeginCreateTag(oid, annotated) => {
775                let tab = self.active_tab_mut();
776                tab.context_menu = None;
777                tab.create_tag_target_oid = Some(oid.clone());
778                tab.create_tag_annotated = *annotated;
779                tab.create_tag_name.clear();
780                tab.create_tag_message.clear();
781                Task::none()
782            }
783
784            Message::TagNameChanged(s) => {
785                self.active_tab_mut().create_tag_name = s.clone();
786                Task::none()
787            }
788
789            Message::TagMessageChanged(s) => {
790                self.active_tab_mut().create_tag_message = s.clone();
791                Task::none()
792            }
793
794            Message::ConfirmCreateTag => {
795                let (oid, name, message, annotated, path) = {
796                    let tab = self.active_tab();
797                    (
798                        tab.create_tag_target_oid.clone(),
799                        tab.create_tag_name.trim().to_string(),
800                        tab.create_tag_message.trim().to_string(),
801                        tab.create_tag_annotated,
802                        tab.repo_path.clone(),
803                    )
804                };
805                if let (Some(oid), false) = (&oid, name.is_empty()) {
806                    if let Some(path) = path {
807                        let oid = oid.clone();
808                        {
809                            let tab = self.active_tab_mut();
810                            tab.create_tag_target_oid = None;
811                            tab.create_tag_name.clear();
812                            tab.create_tag_message.clear();
813                            tab.is_loading = true;
814                            tab.status_message = Some(format!("Creating tag '{name}'…"));
815                        }
816                        return if annotated {
817                            crate::features::repo::commands::create_annotated_tag_async(
818                                path, name, message, oid,
819                            )
820                        } else {
821                            crate::features::repo::commands::create_tag_async(path, name, oid)
822                        };
823                    }
824                }
825                Task::none()
826            }
827
828            Message::CancelCreateTag => {
829                let tab = self.active_tab_mut();
830                tab.create_tag_target_oid = None;
831                tab.create_tag_name.clear();
832                tab.create_tag_message.clear();
833                Task::none()
834            }
835
836            Message::CherryPickCommit(oid) => {
837                let oid = oid.clone();
838                let short = gitkraft_core::utils::short_oid_str(&oid).to_string();
839                self.dismiss_menu();
840                with_repo!(self, loading, format!("Cherry-picking {short}…"), |path| {
841                    crate::features::repo::commands::cherry_pick_async(path, oid)
842                })
843            }
844
845            Message::BeginCreateBranchAtCommit(oid) => {
846                let tab = self.active_tab_mut();
847                tab.context_menu = None;
848                tab.create_branch_at_oid = Some(oid.clone());
849                tab.new_branch_name.clear();
850                Task::none()
851            }
852
853            Message::ConfirmCreateBranchAtCommit => {
854                let (oid, name, path) = {
855                    let tab = self.active_tab();
856                    (
857                        tab.create_branch_at_oid.clone(),
858                        tab.new_branch_name.trim().to_string(),
859                        tab.repo_path.clone(),
860                    )
861                };
862                if let (Some(oid), false) = (&oid, name.is_empty()) {
863                    if let Some(path) = path {
864                        let oid = oid.clone();
865                        {
866                            let tab = self.active_tab_mut();
867                            tab.create_branch_at_oid = None;
868                            tab.new_branch_name.clear();
869                            tab.is_loading = true;
870                            tab.status_message = Some(format!("Creating branch '{name}'…"));
871                        }
872                        return crate::features::repo::commands::create_branch_at_commit_async(
873                            path, name, oid,
874                        );
875                    }
876                }
877                Task::none()
878            }
879
880            Message::CancelCreateBranchAtCommit => {
881                let tab = self.active_tab_mut();
882                tab.create_branch_at_oid = None;
883                tab.new_branch_name.clear();
884                Task::none()
885            }
886
887            // ── Commit context menu actions ───────────────────────────────────────
888            Message::ExecuteCommitAction(oid, action) => {
889                let oid = oid.clone();
890                let action = action.clone();
891                let label = action.label();
892                self.dismiss_menu();
893                with_repo!(self, loading, format!("{label}…"), |path| {
894                    crate::features::repo::commands::execute_commit_action_async(path, oid, action)
895                })
896            }
897
898            Message::CheckoutCommitDetached(oid) => {
899                let oid = oid.clone();
900                let short = gitkraft_core::utils::short_oid_str(&oid).to_string();
901                self.dismiss_menu();
902                with_repo!(self, loading, format!("Checking out {short}…"), |path| {
903                    crate::features::repo::commands::checkout_commit_async(path, oid)
904                })
905            }
906
907            Message::RebaseOntoCommit(oid) => {
908                let oid = oid.clone();
909                let short = gitkraft_core::utils::short_oid_str(&oid).to_string();
910                with_repo!(
911                    self,
912                    dismiss,
913                    loading,
914                    format!("Rebasing onto {short}…"),
915                    |path| crate::features::repo::commands::rebase_onto_async(path, oid)
916                )
917            }
918
919            Message::RevertCommit(oid) => {
920                let oid = oid.clone();
921                let short = gitkraft_core::utils::short_oid_str(&oid).to_string();
922                with_repo!(
923                    self,
924                    dismiss,
925                    loading,
926                    format!("Reverting {short}…"),
927                    |path| crate::features::repo::commands::revert_commit_async(path, oid)
928                )
929            }
930
931            Message::ResetSoft(ref oid)
932            | Message::ResetMixed(ref oid)
933            | Message::ResetHard(ref oid) => {
934                let mode = match &message {
935                    Message::ResetSoft(_) => gitkraft_core::ResetMode::Soft,
936                    Message::ResetMixed(_) => gitkraft_core::ResetMode::Mixed,
937                    Message::ResetHard(_) => gitkraft_core::ResetMode::Hard,
938                    _ => unreachable!(),
939                };
940                let oid = oid.clone();
941                let short = gitkraft_core::utils::short_oid_str(&oid).to_string();
942                with_repo!(
943                    self,
944                    dismiss,
945                    loading,
946                    format!("Resetting ({mode}) to {short}…"),
947                    |path| crate::features::repo::commands::reset_to_commit_async(path, oid, mode)
948                )
949            }
950
951            // ── Shared ───────────────────────────────────────────────────────────────
952            Message::CopyText(text) => {
953                self.active_tab_mut().context_menu = None;
954                iced::clipboard::write(text.clone())
955            }
956
957            // ── Persistence / misc ────────────────────────────────────────
958            Message::ThemeChanged(index) => {
959                self.current_theme_index = *index;
960                // Persist the selected theme name on a background thread.
961                let name = gitkraft_core::THEME_NAMES
962                    .get(*index)
963                    .copied()
964                    .unwrap_or("Default");
965                crate::features::repo::commands::save_theme_async(name.to_string())
966            }
967
968            Message::ThemeSaved(_)
969            | Message::EditorSaved(_)
970            | Message::LayoutSaved(_)
971            | Message::SessionSaved(_) => Task::none(),
972
973            Message::EditorChanged(editor) => {
974                self.editor = editor.clone();
975                self.active_tab_mut().status_message =
976                    Some(format!("Editor set to {}", self.editor));
977                // Persist the editor choice
978                let name = self.editor.display_name().to_string();
979                crate::features::repo::commands::save_editor_async(name)
980            }
981
982            Message::LayoutLoaded(result) => {
983                if let Ok(Some(layout)) = result {
984                    if let Some(w) = layout.sidebar_width {
985                        self.sidebar_width = w;
986                    }
987                    if let Some(w) = layout.commit_log_width {
988                        self.commit_log_width = w;
989                    }
990                    if let Some(h) = layout.staging_height {
991                        self.staging_height = h;
992                    }
993                    if let Some(w) = layout.diff_file_list_width {
994                        self.diff_file_list_width = w;
995                    }
996                    if let Some(expanded) = layout.sidebar_expanded {
997                        self.sidebar_expanded = expanded;
998                    }
999                    if let Some(scale) = layout.ui_scale {
1000                        self.ui_scale = (scale * 10.0).round() / 10.0;
1001                        self.ui_scale = self.ui_scale.clamp(0.5, 2.0);
1002                    }
1003                }
1004                Task::none()
1005            }
1006
1007            // ── Search ────────────────────────────────────────────────────
1008            Message::ToggleSearch => {
1009                self.search_visible = !self.search_visible;
1010                if !self.search_visible {
1011                    self.search_query = String::new();
1012                    self.search_results = Vec::new();
1013                    self.search_selected = None;
1014                    self.search_diff_files = Vec::new();
1015                    self.search_diff_selected = HashSet::new();
1016                    self.search_diff_content = Vec::new();
1017                    self.search_diff_oid = None;
1018                    Task::none()
1019                } else {
1020                    iced::widget::operation::focus_next()
1021                }
1022            }
1023
1024            Message::SearchQueryChanged(query) => {
1025                let query = query.clone();
1026                self.search_query = query.clone();
1027                if query.trim().len() >= 2 {
1028                    if let Some(path) = self.active_tab().repo_path.clone() {
1029                        return crate::features::commits::commands::search_commits(path, query);
1030                    }
1031                } else {
1032                    self.search_results.clear();
1033                    self.search_selected = None;
1034                }
1035                Task::none()
1036            }
1037
1038            Message::SearchResultsLoaded(result) => {
1039                match result {
1040                    Ok(results) => {
1041                        self.search_results = results.clone();
1042                        self.search_selected = if self.search_results.is_empty() {
1043                            None
1044                        } else {
1045                            Some(0)
1046                        };
1047                    }
1048                    Err(e) => {
1049                        self.search_results.clear();
1050                        self.active_tab_mut().error_message = Some(format!("Search failed: {e}"));
1051                    }
1052                }
1053                Task::none()
1054            }
1055
1056            Message::SelectSearchResult(index) => {
1057                let index = *index;
1058                if index < self.search_results.len() {
1059                    self.search_selected = Some(index);
1060                }
1061                Task::none()
1062            }
1063
1064            Message::ConfirmSearchResult => {
1065                if let Some(idx) = self.search_selected {
1066                    if let Some(commit) = self.search_results.get(idx).cloned() {
1067                        let oid = commit.oid.clone();
1068                        // Keep search open — load the file list for commit vs working tree
1069                        self.search_diff_oid = Some(oid.clone());
1070                        self.search_diff_files.clear();
1071                        self.search_diff_selected.clear();
1072                        self.search_diff_content.clear();
1073
1074                        if let Some(path) = self.active_tab().repo_path.clone() {
1075                            return crate::features::commits::commands::search_diff_file_list(
1076                                path, oid,
1077                            );
1078                        }
1079                    }
1080                }
1081                Task::none()
1082            }
1083
1084            Message::SearchDiffFilesLoaded(result) => {
1085                match result {
1086                    Ok(files) => {
1087                        self.search_diff_files = files.clone();
1088                        self.search_diff_selected.clear();
1089                        self.search_diff_content.clear();
1090                    }
1091                    Err(e) => {
1092                        self.active_tab_mut().error_message =
1093                            Some(format!("Failed to load diff files: {e}"));
1094                    }
1095                }
1096                Task::none()
1097            }
1098
1099            Message::ToggleSearchDiffFile(index) => {
1100                let index = *index;
1101                if self.search_diff_selected.contains(&index) {
1102                    self.search_diff_selected.remove(&index);
1103                } else {
1104                    self.search_diff_selected.insert(index);
1105                }
1106                Task::none()
1107            }
1108
1109            Message::ToggleSearchDiffSelectAll => {
1110                if self.search_diff_selected.len() == self.search_diff_files.len() {
1111                    self.search_diff_selected.clear();
1112                } else {
1113                    self.search_diff_selected = (0..self.search_diff_files.len()).collect();
1114                }
1115                Task::none()
1116            }
1117
1118            Message::ViewSearchDiffFile(index) => {
1119                let index = *index;
1120                if let Some(file) = self.search_diff_files.get(index) {
1121                    let file_path = file.display_path().to_string();
1122                    if let (Some(oid), Some(repo_path)) = (
1123                        self.search_diff_oid.clone(),
1124                        self.active_tab().repo_path.clone(),
1125                    ) {
1126                        return crate::features::commits::commands::search_diff_file(
1127                            repo_path, oid, file_path,
1128                        );
1129                    }
1130                }
1131                Task::none()
1132            }
1133
1134            Message::SearchFileDiffLoaded(result) => {
1135                match result {
1136                    Ok(diff) => {
1137                        self.search_diff_content = vec![diff.clone()];
1138                    }
1139                    Err(e) => {
1140                        self.active_tab_mut().error_message =
1141                            Some(format!("Failed to load file diff: {e}"));
1142                    }
1143                }
1144                Task::none()
1145            }
1146
1147            Message::DiffSelectedFiles => {
1148                if self.search_diff_selected.is_empty() {
1149                    return Task::none();
1150                }
1151                let file_paths: Vec<String> = self
1152                    .search_diff_selected
1153                    .iter()
1154                    .filter_map(|&i| self.search_diff_files.get(i))
1155                    .map(|f| f.display_path().to_string())
1156                    .collect();
1157                if let (Some(oid), Some(repo_path)) = (
1158                    self.search_diff_oid.clone(),
1159                    self.active_tab().repo_path.clone(),
1160                ) {
1161                    return crate::features::commits::commands::search_diff_multi_files(
1162                        repo_path, oid, file_paths,
1163                    );
1164                }
1165                Task::none()
1166            }
1167
1168            Message::SearchMultiDiffLoaded(result) => {
1169                match result {
1170                    Ok(diffs) => {
1171                        self.search_diff_content = diffs.clone();
1172                    }
1173                    Err(e) => {
1174                        self.active_tab_mut().error_message =
1175                            Some(format!("Failed to load diffs: {e}"));
1176                    }
1177                }
1178                Task::none()
1179            }
1180
1181            Message::SearchDiffBack => {
1182                self.search_diff_content.clear();
1183                Task::none()
1184            }
1185
1186            Message::FileSystemChanged => {
1187                // Don't refresh while the active tab is performing a full
1188                // load (e.g. opening a new repo).  Background pagination
1189                // (is_loading_more_commits) is NOT checked here — the
1190                // "don't shrink" guard in apply_payload already prevents
1191                // a stale refresh from overwriting a larger paginated list.
1192                // Checking is_loading_more_commits here would permanently
1193                // block refreshes if the flag got stuck (e.g. user switched
1194                // tabs while pagination was in flight).
1195                if self.has_repo() && !self.active_tab().is_loading {
1196                    return self.refresh_active_tab();
1197                }
1198                Task::none()
1199            }
1200
1201            Message::OpenInEditor(path) => {
1202                self.active_tab_mut().context_menu = None;
1203                if matches!(self.editor, gitkraft_core::Editor::None) {
1204                    self.active_tab_mut().status_message = Some(
1205                        "No editor configured — select one from the editor dropdown in the toolbar"
1206                            .into(),
1207                    );
1208                    return Task::none();
1209                }
1210                if let Some(repo_path) = self.active_tab().repo_path.as_ref() {
1211                    let full_path = repo_path.join(path);
1212                    match self.editor.open_file(&full_path) {
1213                        Ok(()) => {
1214                            self.active_tab_mut().status_message =
1215                                Some(format!("Opened in {}", self.editor));
1216                        }
1217                        Err(e) => {
1218                            self.active_tab_mut().error_message =
1219                                Some(format!("Failed to open editor: {e}"));
1220                        }
1221                    }
1222                }
1223                Task::none()
1224            }
1225
1226            Message::OpenInDefaultProgram(path) => {
1227                self.active_tab_mut().context_menu = None;
1228                if let Some(repo_path) = self.active_tab().repo_path.as_ref() {
1229                    let full_path = repo_path.join(path);
1230                    if let Err(e) = gitkraft_core::open_file_default(&full_path) {
1231                        self.active_tab_mut().error_message =
1232                            Some(format!("Failed to open file: {e}"));
1233                    }
1234                }
1235                Task::none()
1236            }
1237
1238            Message::OpenFilesInEditor(paths) => {
1239                self.active_tab_mut().context_menu = None;
1240                if matches!(self.editor, gitkraft_core::Editor::None) {
1241                    self.active_tab_mut().status_message = Some(
1242                        "No editor configured — select one from the editor dropdown in the toolbar"
1243                            .into(),
1244                    );
1245                    return Task::none();
1246                }
1247                if let Some(repo_path) = self.active_tab().repo_path.clone() {
1248                    let mut last_err: Option<String> = None;
1249                    for p in paths.iter() {
1250                        let full_path = repo_path.join(p);
1251                        if let Err(e) = self.editor.open_file(&full_path) {
1252                            last_err = Some(format!("{p}: {e}"));
1253                        }
1254                    }
1255                    if let Some(e) = last_err {
1256                        self.active_tab_mut().error_message =
1257                            Some(format!("Failed to open in editor: {e}"));
1258                    } else {
1259                        self.active_tab_mut().status_message =
1260                            Some(format!("Opened {} file(s) in {}", paths.len(), self.editor));
1261                    }
1262                }
1263                Task::none()
1264            }
1265
1266            Message::OpenFilesInDefaultProgram(paths) => {
1267                self.active_tab_mut().context_menu = None;
1268                if let Some(repo_path) = self.active_tab().repo_path.clone() {
1269                    for p in paths.iter() {
1270                        let full_path = repo_path.join(p);
1271                        if let Err(e) = gitkraft_core::open_file_default(&full_path) {
1272                            self.active_tab_mut().error_message =
1273                                Some(format!("Failed to open file: {e}"));
1274                            break;
1275                        }
1276                    }
1277                }
1278                Task::none()
1279            }
1280
1281            Message::PreviewFile(path) => {
1282                self.active_tab_mut().context_menu = None;
1283                if let Some(repo_path) = self.active_tab().repo_path.clone() {
1284                    let full_path = repo_path.join(path);
1285                    let file_path = full_path.display().to_string();
1286                    git_task!(
1287                        Message::FilePreviewLoaded,
1288                        std::fs::read_to_string(&full_path)
1289                            .map(|content| (file_path, content))
1290                            .str_err()
1291                    )
1292                } else {
1293                    Task::none()
1294                }
1295            }
1296
1297            Message::FilePreviewLoaded(result) => {
1298                match result {
1299                    Ok((file_path, content)) => {
1300                        self.preview_editor =
1301                            Some(iced::widget::text_editor::Content::with_text(content));
1302                        self.preview_path = Some(file_path.clone());
1303                        // Clear the diff view so the preview takes over
1304                        self.active_tab_mut().selected_diff = None;
1305                    }
1306                    Err(e) => {
1307                        self.active_tab_mut().error_message =
1308                            Some(format!("Cannot preview file: {e}"));
1309                    }
1310                }
1311                Task::none()
1312            }
1313
1314            Message::ShowInFolder(path) => {
1315                self.active_tab_mut().context_menu = None;
1316                if let Some(repo_path) = self.active_tab().repo_path.as_ref() {
1317                    let full_path = repo_path.join(path);
1318                    if let Err(e) = gitkraft_core::show_in_folder(&full_path) {
1319                        self.active_tab_mut().error_message =
1320                            Some(format!("Failed to show in folder: {e}"));
1321                    }
1322                }
1323                Task::none()
1324            }
1325
1326            // ── File history overlay ──────────────────────────────────────────────
1327            Message::ViewFileHistory(path) => {
1328                let path = path.clone();
1329                if let Some(repo_path) = self.active_tab().repo_path.clone() {
1330                    let tab = self.active_tab_mut();
1331                    tab.blame_path = None; // close blame if open
1332                    tab.file_history_path = Some(path.clone());
1333                    tab.file_history_commits.clear();
1334                    tab.file_history_scroll = 0.0;
1335                    tab.context_menu = None;
1336                    tab.status_message = Some(format!(
1337                        "Loading history for {}…",
1338                        gitkraft_core::path_basename(&path)
1339                    ));
1340                    crate::features::repo::commands::file_history_async(repo_path, path)
1341                } else {
1342                    Task::none()
1343                }
1344            }
1345
1346            Message::FileHistoryLoaded(result) => {
1347                match result {
1348                    Ok((path, commits)) => {
1349                        let tab = self.active_tab_mut();
1350                        tab.file_history_path = Some(path.clone());
1351                        tab.file_history_commits = commits.clone();
1352                        tab.status_message = Some(format!(
1353                            "{} commits touch {}",
1354                            commits.len(),
1355                            gitkraft_core::path_basename(path)
1356                        ));
1357                    }
1358                    Err(e) => {
1359                        let tab = self.active_tab_mut();
1360                        tab.file_history_path = None;
1361                        tab.error_message = Some(format!("File history failed: {e}"));
1362                    }
1363                }
1364                Task::none()
1365            }
1366
1367            Message::CloseFileHistory => {
1368                let tab = self.active_tab_mut();
1369                tab.file_history_path = None;
1370                tab.file_history_commits = Vec::new();
1371                tab.file_history_scroll = 0.0;
1372                Task::none()
1373            }
1374
1375            Message::FileHistoryScrolled(y) => {
1376                self.active_tab_mut().file_history_scroll = *y;
1377                Task::none()
1378            }
1379
1380            Message::SelectFileHistoryCommit(oid) => {
1381                let oid = oid.clone();
1382                let repo_path = self.active_tab().repo_path.clone();
1383                {
1384                    let tab = self.active_tab_mut();
1385                    tab.file_history_path = None;
1386                    tab.file_history_commits.clear();
1387                    tab.selected_commit_oid = Some(oid.clone());
1388                    tab.commit_files.clear();
1389                    tab.selected_diff = None;
1390                    tab.show_commit_detail = true;
1391                }
1392                if let Some(path) = repo_path {
1393                    crate::features::commits::commands::load_commit_file_list(path, oid)
1394                } else {
1395                    Task::none()
1396                }
1397            }
1398
1399            // ── Blame overlay ─────────────────────────────────────────────────────
1400            Message::ViewFileBlame(path) => {
1401                let path = path.clone();
1402                if let Some(repo_path) = self.active_tab().repo_path.clone() {
1403                    let tab = self.active_tab_mut();
1404                    tab.file_history_path = None; // close history if open
1405                    tab.blame_path = Some(path.clone());
1406                    tab.blame_lines.clear();
1407                    tab.blame_scroll = 0.0;
1408                    tab.context_menu = None;
1409                    tab.status_message = Some(format!(
1410                        "Loading blame for {}…",
1411                        gitkraft_core::path_basename(&path)
1412                    ));
1413                    crate::features::repo::commands::blame_file_async(repo_path, path)
1414                } else {
1415                    Task::none()
1416                }
1417            }
1418
1419            Message::FileBlameLoaded(result) => {
1420                match result {
1421                    Ok((path, lines)) => {
1422                        let tab = self.active_tab_mut();
1423                        tab.blame_path = Some(path.clone());
1424                        tab.blame_lines = lines.clone();
1425                        tab.status_message = Some(format!(
1426                            "Blame: {} ({} lines)",
1427                            gitkraft_core::path_basename(path),
1428                            lines.len()
1429                        ));
1430                    }
1431                    Err(e) => {
1432                        let tab = self.active_tab_mut();
1433                        tab.blame_path = None;
1434                        tab.error_message = Some(format!("Blame failed: {e}"));
1435                    }
1436                }
1437                Task::none()
1438            }
1439
1440            Message::CloseFileBlame => {
1441                let tab = self.active_tab_mut();
1442                tab.blame_path = None;
1443                tab.blame_lines = Vec::new();
1444                tab.blame_scroll = 0.0;
1445                // Also dismiss context menu and search overlay on Escape.
1446                tab.context_menu = None;
1447                self.show_about = false;
1448                if self.search_visible {
1449                    self.search_visible = false;
1450                    self.search_query = String::new();
1451                    self.search_results = Vec::new();
1452                    self.search_selected = None;
1453                    self.search_diff_files = Vec::new();
1454                    self.search_diff_selected = HashSet::new();
1455                    self.search_diff_content = Vec::new();
1456                    self.search_diff_oid = None;
1457                }
1458                Task::none()
1459            }
1460
1461            Message::BlameScrolled(y) => {
1462                self.active_tab_mut().blame_scroll = *y;
1463                Task::none()
1464            }
1465
1466            // ── File deletion ─────────────────────────────────────────────────────
1467            Message::DeleteFile(path) => {
1468                let tab = self.active_tab_mut();
1469                tab.context_menu = None;
1470                tab.pending_delete_file = Some(path.clone());
1471                tab.status_message = Some(format!(
1472                    "Delete '{}' — press Confirm to delete permanently",
1473                    gitkraft_core::path_basename(path)
1474                ));
1475                Task::none()
1476            }
1477
1478            Message::ConfirmDeleteFile => {
1479                let path = self.active_tab().pending_delete_file.clone();
1480                let repo_path = self.active_tab().repo_path.clone();
1481                if let (Some(file_path), Some(repo_path)) = (path, repo_path) {
1482                    let tab = self.active_tab_mut();
1483                    tab.pending_delete_file = None;
1484                    tab.is_loading = true;
1485                    tab.status_message = Some(format!(
1486                        "Deleting '{}'…",
1487                        gitkraft_core::path_basename(&file_path)
1488                    ));
1489                    crate::features::repo::commands::delete_file_async(repo_path, file_path)
1490                } else {
1491                    Task::none()
1492                }
1493            }
1494
1495            Message::CancelDeleteFile => {
1496                let tab = self.active_tab_mut();
1497                tab.pending_delete_file = None;
1498                tab.status_message = None;
1499                Task::none()
1500            }
1501
1502            Message::PreviewEditorAction(action) => {
1503                if let Some(ref mut content) = self.preview_editor {
1504                    content.perform(action.clone());
1505                }
1506                Task::none()
1507            }
1508
1509            Message::ClosePreview => {
1510                clear_preview!(self);
1511                Task::none()
1512            }
1513
1514            Message::HoverCommit(idx) => {
1515                self.active_tab_mut().hovered_commit = *idx;
1516                Task::none()
1517            }
1518
1519            Message::Noop => Task::none(),
1520
1521            Message::CherryPickCommits(oids) => {
1522                let oids = oids.clone();
1523                with_repo!(
1524                    self,
1525                    dismiss,
1526                    loading,
1527                    format!("Cherry-picking {} commit(s)…", oids.len()),
1528                    |path| crate::features::repo::commands::cherry_pick_commits_async(path, oids)
1529                )
1530            }
1531
1532            Message::RevertCommits(oids) => {
1533                let oids = oids.clone();
1534                with_repo!(
1535                    self,
1536                    dismiss,
1537                    loading,
1538                    format!("Reverting {} commit(s)…", oids.len()),
1539                    |path| crate::features::repo::commands::revert_commits_async(path, oids)
1540                )
1541            }
1542        }
1543    }
1544}