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