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::OpenSettingsFile => {
362                // Resolve the settings file path.
363                let path = match gitkraft_core::features::persistence::ops::settings_json_path() {
364                    Ok(p) => p,
365                    Err(e) => {
366                        let msg = format!("Cannot determine settings path: {e}");
367                        self.active_tab_mut().error_message = Some(msg);
368                        return Task::none();
369                    }
370                };
371
372                // Ensure the file exists so the editor can open it immediately.
373                if !path.exists() {
374                    let snap = gitkraft_core::features::persistence::ops::load_settings()
375                        .unwrap_or_default();
376                    if let Err(e) = gitkraft_core::features::persistence::ops::save_settings(&snap)
377                    {
378                        let msg = format!("Could not create settings file: {e}");
379                        self.active_tab_mut().error_message = Some(msg);
380                        return Task::none();
381                    }
382                }
383
384                let path_str = path.display().to_string();
385
386                // open_file_or_default tries the configured editor first, then
387                // falls back to the system default opener (xdg-open / open /
388                // start).  On macOS, GUI editors are activated via `open -a`
389                // so the existing window is brought to the front.
390                match self.editor.open_file_or_default(&path) {
391                    Ok(method) => {
392                        let msg = format!("Settings opened in {method} — {path_str}");
393                        self.active_tab_mut().status_message = Some(msg);
394                    }
395                    Err(e) => {
396                        // Opening failed entirely — show the path so the user
397                        // can find and open the file manually.
398                        let msg = format!("Could not open settings ({e}) — file is at: {path_str}");
399                        self.active_tab_mut().error_message = Some(msg);
400                    }
401                }
402                Task::none()
403            }
404
405            // ── Pane resize ───────────────────────────────────────────────
406            Message::PaneDragStart(target, _x) => {
407                self.dragging = Some(*target);
408                // Position is 0.0 because `on_press` doesn't provide coords.
409                // We set drag_initialized to false so the first `PaneDragMove`
410                // captures the real position instead of computing a bogus delta.
411                self.drag_initialized = false;
412                Task::none()
413            }
414
415            Message::PaneDragStartH(target, _y) => {
416                self.dragging_h = Some(*target);
417                self.drag_initialized_h = false;
418                Task::none()
419            }
420
421            Message::PaneDragMove(x, y) => {
422                use crate::state::{DragTarget, DragTargetH};
423
424                // Always record cursor position so context menus open at the pointer.
425                self.cursor_pos = iced::Point::new(*x, *y);
426
427                if let Some(target) = self.dragging {
428                    if !self.drag_initialized {
429                        // First move after press — just record the position.
430                        self.drag_start_x = *x;
431                        self.drag_initialized = true;
432                    } else {
433                        let dx = *x - self.drag_start_x;
434                        self.drag_start_x = *x;
435
436                        match target {
437                            DragTarget::SidebarRight => {
438                                self.sidebar_width = (self.sidebar_width + dx).clamp(120.0, 500.0);
439                            }
440                            DragTarget::CommitLogRight => {
441                                self.commit_log_width =
442                                    (self.commit_log_width + dx).clamp(200.0, 1200.0);
443                            }
444                            DragTarget::DiffFileListRight => {
445                                self.diff_file_list_width =
446                                    (self.diff_file_list_width + dx).clamp(100.0, 400.0);
447                            }
448                            DragTarget::StagingUnstagedRight => {
449                                let staging_w = self.window_width;
450                                if staging_w > 0.0 {
451                                    let ratio_delta = dx / staging_w;
452                                    self.staging_unstaged_ratio = (self.staging_unstaged_ratio
453                                        + ratio_delta)
454                                        .clamp(0.15, 0.7);
455                                    let max_staged =
456                                        (1.0 - self.staging_unstaged_ratio - 0.1).max(0.1);
457                                    if self.staging_staged_ratio > max_staged {
458                                        self.staging_staged_ratio = max_staged;
459                                    }
460                                }
461                            }
462                            DragTarget::StagingStagedRight => {
463                                let staging_w = self.window_width;
464                                if staging_w > 0.0 {
465                                    let ratio_delta = dx / staging_w;
466                                    let max_staged =
467                                        (1.0 - self.staging_unstaged_ratio - 0.1).max(0.1);
468                                    self.staging_staged_ratio = (self.staging_staged_ratio
469                                        + ratio_delta)
470                                        .clamp(0.1, max_staged);
471                                }
472                            }
473                            DragTarget::CommitRefColumnRight => {
474                                self.commit_ref_width =
475                                    (self.commit_ref_width + dx).clamp(60.0, 250.0);
476                            }
477                            DragTarget::CommitGraphColumnRight => {
478                                self.commit_graph_width =
479                                    (self.commit_graph_width + dx).clamp(24.0, 300.0);
480                            }
481                        }
482                    }
483                }
484
485                if let Some(target_h) = self.dragging_h {
486                    if !self.drag_initialized_h {
487                        self.drag_start_y = *y;
488                        self.drag_initialized_h = true;
489                    } else {
490                        let dy = *y - self.drag_start_y;
491                        self.drag_start_y = *y;
492
493                        match target_h {
494                            DragTargetH::StagingTop => {
495                                // Dragging up → larger staging area (subtract dy).
496                                self.staging_height =
497                                    (self.staging_height - dy).clamp(100.0, 600.0);
498                            }
499                            DragTargetH::SidebarBranchesBottom => {
500                                // Convert pixel delta to ratio delta based on
501                                // the approximate sidebar height.
502                                let sidebar_h = self.window_height - 120.0;
503                                if sidebar_h > 0.0 {
504                                    let ratio_delta = dy / sidebar_h;
505                                    self.sidebar_branches_ratio =
506                                        (self.sidebar_branches_ratio + ratio_delta).clamp(0.1, 0.8);
507                                    // Ensure stash doesn't get squeezed out.
508                                    let max_stash =
509                                        (1.0 - self.sidebar_branches_ratio - 0.05).max(0.05);
510                                    if self.sidebar_stash_ratio > max_stash {
511                                        self.sidebar_stash_ratio = max_stash;
512                                    }
513                                }
514                            }
515                            DragTargetH::SidebarStashBottom => {
516                                let sidebar_h = self.window_height - 120.0;
517                                if sidebar_h > 0.0 {
518                                    let ratio_delta = dy / sidebar_h;
519                                    let max_stash =
520                                        (1.0 - self.sidebar_branches_ratio - 0.05).max(0.05);
521                                    self.sidebar_stash_ratio = (self.sidebar_stash_ratio
522                                        + ratio_delta)
523                                        .clamp(0.05, max_stash);
524                                }
525                            }
526                        }
527                    }
528                }
529
530                Task::none()
531            }
532
533            Message::PaneDragEnd => {
534                self.dragging = None;
535                self.dragging_h = None;
536                self.drag_initialized = false;
537                self.drag_initialized_h = false;
538                crate::features::repo::commands::save_layout_async(self.current_layout())
539            }
540
541            // ── Context menu lifecycle ────────────────────────────────────────────────
542            Message::OpenBranchContextMenu(name, local_index, is_current) => {
543                self.open_context_menu(crate::state::ContextMenu::Branch {
544                    name: name.clone(),
545                    is_current: *is_current,
546                    local_index: *local_index,
547                });
548                Task::none()
549            }
550
551            Message::OpenRemoteBranchContextMenu(name) => {
552                self.open_context_menu(crate::state::ContextMenu::RemoteBranch {
553                    name: name.clone(),
554                });
555                Task::none()
556            }
557
558            Message::OpenCommitFileContextMenu(oid, file_path) => {
559                self.open_context_menu(crate::state::ContextMenu::CommitFile {
560                    oid: oid.clone(),
561                    file_path: file_path.clone(),
562                });
563                Task::none()
564            }
565
566            Message::OpenStashContextMenu(index) => {
567                let index = *index;
568                self.open_context_menu(crate::state::ContextMenu::Stash { index });
569                Task::none()
570            }
571
572            Message::OpenUnstagedFileContextMenu(path) => {
573                self.open_context_menu(crate::state::ContextMenu::UnstagedFile {
574                    path: path.clone(),
575                });
576                Task::none()
577            }
578
579            Message::OpenStagedFileContextMenu(path) => {
580                self.open_context_menu(crate::state::ContextMenu::StagedFile {
581                    path: path.clone(),
582                });
583                Task::none()
584            }
585
586            Message::OpenCommitContextMenu(idx) => {
587                let oid = self.active_tab().commits.get(*idx).map(|c| c.oid.clone());
588                if let Some(oid) = oid {
589                    self.open_context_menu(crate::state::ContextMenu::Commit { index: *idx, oid });
590                }
591                Task::none()
592            }
593
594            Message::OpenSearchResultContextMenu(idx) => {
595                if let Some(commit) = self.search_results.get(*idx) {
596                    let oid = commit.oid.clone();
597                    self.open_context_menu(crate::state::ContextMenu::Commit { index: *idx, oid });
598                }
599                Task::none()
600            }
601
602            Message::CloseContextMenu => {
603                self.dismiss_menu();
604                Task::none()
605            }
606
607            // ── Inline branch rename ──────────────────────────────────────────────────
608            Message::BeginRenameBranch(name) => {
609                let tab = self.active_tab_mut();
610                tab.context_menu = None;
611                tab.rename_branch_input = name.clone();
612                tab.rename_branch_target = Some(name.clone());
613                Task::none()
614            }
615
616            Message::RenameBranchInputChanged(s) => {
617                self.active_tab_mut().rename_branch_input = s.clone();
618                Task::none()
619            }
620
621            Message::CancelRename => {
622                let tab = self.active_tab_mut();
623                tab.rename_branch_target = None;
624                tab.rename_branch_input.clear();
625                Task::none()
626            }
627
628            Message::ConfirmRenameBranch => {
629                let (original, new_name, path) = {
630                    let tab = self.active_tab();
631                    (
632                        tab.rename_branch_target.clone(),
633                        tab.rename_branch_input.trim().to_string(),
634                        tab.repo_path.clone(),
635                    )
636                };
637                if let (Some(orig), false) = (&original, new_name.is_empty()) {
638                    if *orig != new_name {
639                        if let Some(path) = path {
640                            let orig = orig.clone();
641                            {
642                                let tab = self.active_tab_mut();
643                                tab.rename_branch_target = None;
644                                tab.rename_branch_input.clear();
645                                tab.is_loading = true;
646                                tab.status_message =
647                                    Some(format!("Renaming '{orig}' → '{new_name}'…"));
648                            }
649                            return crate::features::repo::commands::rename_branch_async(
650                                path, orig, new_name,
651                            );
652                        }
653                    }
654                }
655                self.active_tab_mut().rename_branch_target = None;
656                Task::none()
657            }
658
659            // ── Branch context menu actions ───────────────────────────────────────────────
660            Message::PushBranch(name) => {
661                let name = name.clone();
662                let remote = self.active_tab().default_remote();
663                self.dismiss_menu();
664                with_repo!(
665                    self,
666                    loading,
667                    format!("Pushing '{name}' to {remote}…"),
668                    |path| crate::features::repo::commands::push_branch_async(path, name, remote)
669                )
670            }
671
672            Message::ForcePushBranch => {
673                let branch = self.active_tab().current_branch.clone().unwrap_or_default();
674                let remote = self.active_tab().default_remote();
675                with_repo!(
676                    self,
677                    loading,
678                    format!("Force pushing '{branch}' to {remote}…"),
679                    |path| crate::features::repo::commands::force_push_branch_async(
680                        path, branch, remote
681                    )
682                )
683            }
684
685            Message::PullBranch(_name) => {
686                let remote = self.active_tab().default_remote();
687                self.dismiss_menu();
688                with_repo!(
689                    self,
690                    loading,
691                    format!("Pulling from {remote} (rebase)…"),
692                    |path| crate::features::repo::commands::pull_rebase_async(path, remote)
693                )
694            }
695
696            Message::RebaseOnto(target) => {
697                let target = target.clone();
698                self.dismiss_menu();
699                with_repo!(
700                    self,
701                    loading,
702                    format!("Rebasing onto '{target}'…"),
703                    |path| crate::features::repo::commands::rebase_onto_async(path, target)
704                )
705            }
706
707            Message::MergeBranch(name) => {
708                let name = name.clone();
709                self.dismiss_menu();
710                with_repo!(
711                    self,
712                    loading,
713                    format!("Merging '{name}' into current branch…"),
714                    |path| crate::features::repo::commands::merge_branch_async(path, name)
715                )
716            }
717
718            Message::CheckoutRemoteBranch(name) => {
719                let name = name.clone();
720                self.dismiss_menu();
721                with_repo!(self, loading, format!("Checking out '{name}'…"), |path| {
722                    crate::features::repo::commands::checkout_remote_branch_async(path, name)
723                })
724            }
725
726            Message::DeleteRemoteBranch(name) => {
727                let name = name.clone();
728                self.dismiss_menu();
729                with_repo!(
730                    self,
731                    loading,
732                    format!("Deleting remote branch '{name}'…"),
733                    |path| crate::features::repo::commands::delete_remote_branch_async(path, name)
734                )
735            }
736
737            Message::BeginCreateTag(oid, annotated) => {
738                let tab = self.active_tab_mut();
739                tab.context_menu = None;
740                tab.create_tag_target_oid = Some(oid.clone());
741                tab.create_tag_annotated = *annotated;
742                tab.create_tag_name.clear();
743                tab.create_tag_message.clear();
744                Task::none()
745            }
746
747            Message::TagNameChanged(s) => {
748                self.active_tab_mut().create_tag_name = s.clone();
749                Task::none()
750            }
751
752            Message::TagMessageChanged(s) => {
753                self.active_tab_mut().create_tag_message = s.clone();
754                Task::none()
755            }
756
757            Message::ConfirmCreateTag => {
758                let (oid, name, message, annotated, path) = {
759                    let tab = self.active_tab();
760                    (
761                        tab.create_tag_target_oid.clone(),
762                        tab.create_tag_name.trim().to_string(),
763                        tab.create_tag_message.trim().to_string(),
764                        tab.create_tag_annotated,
765                        tab.repo_path.clone(),
766                    )
767                };
768                if let (Some(oid), false) = (&oid, name.is_empty()) {
769                    if let Some(path) = path {
770                        let oid = oid.clone();
771                        {
772                            let tab = self.active_tab_mut();
773                            tab.create_tag_target_oid = None;
774                            tab.create_tag_name.clear();
775                            tab.create_tag_message.clear();
776                            tab.is_loading = true;
777                            tab.status_message = Some(format!("Creating tag '{name}'…"));
778                        }
779                        return if annotated {
780                            crate::features::repo::commands::create_annotated_tag_async(
781                                path, name, message, oid,
782                            )
783                        } else {
784                            crate::features::repo::commands::create_tag_async(path, name, oid)
785                        };
786                    }
787                }
788                Task::none()
789            }
790
791            Message::CancelCreateTag => {
792                let tab = self.active_tab_mut();
793                tab.create_tag_target_oid = None;
794                tab.create_tag_name.clear();
795                tab.create_tag_message.clear();
796                Task::none()
797            }
798
799            Message::CherryPickCommit(oid) => {
800                let oid = oid.clone();
801                let short = gitkraft_core::utils::short_oid_str(&oid).to_string();
802                self.dismiss_menu();
803                with_repo!(self, loading, format!("Cherry-picking {short}…"), |path| {
804                    crate::features::repo::commands::cherry_pick_async(path, oid)
805                })
806            }
807
808            Message::BeginCreateBranchAtCommit(oid) => {
809                let tab = self.active_tab_mut();
810                tab.context_menu = None;
811                tab.create_branch_at_oid = Some(oid.clone());
812                tab.new_branch_name.clear();
813                Task::none()
814            }
815
816            Message::ConfirmCreateBranchAtCommit => {
817                let (oid, name, path) = {
818                    let tab = self.active_tab();
819                    (
820                        tab.create_branch_at_oid.clone(),
821                        tab.new_branch_name.trim().to_string(),
822                        tab.repo_path.clone(),
823                    )
824                };
825                if let (Some(oid), false) = (&oid, name.is_empty()) {
826                    if let Some(path) = path {
827                        let oid = oid.clone();
828                        {
829                            let tab = self.active_tab_mut();
830                            tab.create_branch_at_oid = None;
831                            tab.new_branch_name.clear();
832                            tab.is_loading = true;
833                            tab.status_message = Some(format!("Creating branch '{name}'…"));
834                        }
835                        return crate::features::repo::commands::create_branch_at_commit_async(
836                            path, name, oid,
837                        );
838                    }
839                }
840                Task::none()
841            }
842
843            Message::CancelCreateBranchAtCommit => {
844                let tab = self.active_tab_mut();
845                tab.create_branch_at_oid = None;
846                tab.new_branch_name.clear();
847                Task::none()
848            }
849
850            // ── Commit context menu actions ───────────────────────────────────────
851            Message::ExecuteCommitAction(oid, action) => {
852                let oid = oid.clone();
853                let action = action.clone();
854                let label = action.label();
855                self.dismiss_menu();
856                with_repo!(self, loading, format!("{label}…"), |path| {
857                    crate::features::repo::commands::execute_commit_action_async(path, oid, action)
858                })
859            }
860
861            Message::CheckoutCommitDetached(oid) => {
862                let oid = oid.clone();
863                let short = gitkraft_core::utils::short_oid_str(&oid).to_string();
864                self.dismiss_menu();
865                with_repo!(self, loading, format!("Checking out {short}…"), |path| {
866                    crate::features::repo::commands::checkout_commit_async(path, oid)
867                })
868            }
869
870            Message::RebaseOntoCommit(oid) => {
871                let oid = oid.clone();
872                let short = gitkraft_core::utils::short_oid_str(&oid).to_string();
873                with_repo!(
874                    self,
875                    dismiss,
876                    loading,
877                    format!("Rebasing onto {short}…"),
878                    |path| crate::features::repo::commands::rebase_onto_async(path, oid)
879                )
880            }
881
882            Message::RevertCommit(oid) => {
883                let oid = oid.clone();
884                let short = gitkraft_core::utils::short_oid_str(&oid).to_string();
885                with_repo!(
886                    self,
887                    dismiss,
888                    loading,
889                    format!("Reverting {short}…"),
890                    |path| crate::features::repo::commands::revert_commit_async(path, oid)
891                )
892            }
893
894            Message::ResetSoft(ref oid)
895            | Message::ResetMixed(ref oid)
896            | Message::ResetHard(ref oid) => {
897                let mode = match &message {
898                    Message::ResetSoft(_) => gitkraft_core::ResetMode::Soft,
899                    Message::ResetMixed(_) => gitkraft_core::ResetMode::Mixed,
900                    Message::ResetHard(_) => gitkraft_core::ResetMode::Hard,
901                    _ => unreachable!(),
902                };
903                let oid = oid.clone();
904                let short = gitkraft_core::utils::short_oid_str(&oid).to_string();
905                with_repo!(
906                    self,
907                    dismiss,
908                    loading,
909                    format!("Resetting ({mode}) to {short}…"),
910                    |path| crate::features::repo::commands::reset_to_commit_async(path, oid, mode)
911                )
912            }
913
914            // ── Shared ───────────────────────────────────────────────────────────────
915            Message::CopyText(text) => {
916                self.active_tab_mut().context_menu = None;
917                iced::clipboard::write(text.clone())
918            }
919
920            // ── Persistence / misc ────────────────────────────────────────
921            Message::ThemeChanged(index) => {
922                self.current_theme_index = *index;
923                // Persist the selected theme name on a background thread.
924                let name = gitkraft_core::THEME_NAMES
925                    .get(*index)
926                    .copied()
927                    .unwrap_or("Default");
928                crate::features::repo::commands::save_theme_async(name.to_string())
929            }
930
931            Message::ThemeSaved(_)
932            | Message::EditorSaved(_)
933            | Message::LayoutSaved(_)
934            | Message::SessionSaved(_) => Task::none(),
935
936            Message::EditorChanged(editor) => {
937                self.editor = editor.clone();
938                self.active_tab_mut().status_message =
939                    Some(format!("Editor set to {}", self.editor));
940                // Persist the editor choice
941                let name = self.editor.display_name().to_string();
942                crate::features::repo::commands::save_editor_async(name)
943            }
944
945            Message::LayoutLoaded(result) => {
946                if let Ok(Some(layout)) = result {
947                    if let Some(w) = layout.sidebar_width {
948                        self.sidebar_width = w;
949                    }
950                    if let Some(w) = layout.commit_log_width {
951                        self.commit_log_width = w;
952                    }
953                    if let Some(h) = layout.staging_height {
954                        self.staging_height = h;
955                    }
956                    if let Some(w) = layout.diff_file_list_width {
957                        self.diff_file_list_width = w;
958                    }
959                    if let Some(expanded) = layout.sidebar_expanded {
960                        self.sidebar_expanded = expanded;
961                    }
962                    if let Some(scale) = layout.ui_scale {
963                        self.ui_scale = (scale * 10.0).round() / 10.0;
964                        self.ui_scale = self.ui_scale.clamp(0.5, 2.0);
965                    }
966                }
967                Task::none()
968            }
969
970            // ── Search ────────────────────────────────────────────────────
971            Message::ToggleSearch => {
972                self.search_visible = !self.search_visible;
973                if !self.search_visible {
974                    self.search_query = String::new();
975                    self.search_results = Vec::new();
976                    self.search_selected = None;
977                    self.search_diff_files = Vec::new();
978                    self.search_diff_selected = HashSet::new();
979                    self.search_diff_content = Vec::new();
980                    self.search_diff_oid = None;
981                    Task::none()
982                } else {
983                    iced::widget::operation::focus_next()
984                }
985            }
986
987            Message::SearchQueryChanged(query) => {
988                let query = query.clone();
989                self.search_query = query.clone();
990                if query.trim().len() >= 2 {
991                    if let Some(path) = self.active_tab().repo_path.clone() {
992                        return crate::features::commits::commands::search_commits(path, query);
993                    }
994                } else {
995                    self.search_results.clear();
996                    self.search_selected = None;
997                }
998                Task::none()
999            }
1000
1001            Message::SearchResultsLoaded(result) => {
1002                match result {
1003                    Ok(results) => {
1004                        self.search_results = results.clone();
1005                        self.search_selected = if self.search_results.is_empty() {
1006                            None
1007                        } else {
1008                            Some(0)
1009                        };
1010                    }
1011                    Err(e) => {
1012                        self.search_results.clear();
1013                        self.active_tab_mut().error_message = Some(format!("Search failed: {e}"));
1014                    }
1015                }
1016                Task::none()
1017            }
1018
1019            Message::SelectSearchResult(index) => {
1020                let index = *index;
1021                if index < self.search_results.len() {
1022                    self.search_selected = Some(index);
1023                }
1024                Task::none()
1025            }
1026
1027            Message::ConfirmSearchResult => {
1028                if let Some(idx) = self.search_selected {
1029                    if let Some(commit) = self.search_results.get(idx).cloned() {
1030                        let oid = commit.oid.clone();
1031                        // Keep search open — load the file list for commit vs working tree
1032                        self.search_diff_oid = Some(oid.clone());
1033                        self.search_diff_files.clear();
1034                        self.search_diff_selected.clear();
1035                        self.search_diff_content.clear();
1036
1037                        if let Some(path) = self.active_tab().repo_path.clone() {
1038                            return crate::features::commits::commands::search_diff_file_list(
1039                                path, oid,
1040                            );
1041                        }
1042                    }
1043                }
1044                Task::none()
1045            }
1046
1047            Message::SearchDiffFilesLoaded(result) => {
1048                match result {
1049                    Ok(files) => {
1050                        self.search_diff_files = files.clone();
1051                        self.search_diff_selected.clear();
1052                        self.search_diff_content.clear();
1053                    }
1054                    Err(e) => {
1055                        self.active_tab_mut().error_message =
1056                            Some(format!("Failed to load diff files: {e}"));
1057                    }
1058                }
1059                Task::none()
1060            }
1061
1062            Message::ToggleSearchDiffFile(index) => {
1063                let index = *index;
1064                if self.search_diff_selected.contains(&index) {
1065                    self.search_diff_selected.remove(&index);
1066                } else {
1067                    self.search_diff_selected.insert(index);
1068                }
1069                Task::none()
1070            }
1071
1072            Message::ToggleSearchDiffSelectAll => {
1073                if self.search_diff_selected.len() == self.search_diff_files.len() {
1074                    self.search_diff_selected.clear();
1075                } else {
1076                    self.search_diff_selected = (0..self.search_diff_files.len()).collect();
1077                }
1078                Task::none()
1079            }
1080
1081            Message::ViewSearchDiffFile(index) => {
1082                let index = *index;
1083                if let Some(file) = self.search_diff_files.get(index) {
1084                    let file_path = file.display_path().to_string();
1085                    if let (Some(oid), Some(repo_path)) = (
1086                        self.search_diff_oid.clone(),
1087                        self.active_tab().repo_path.clone(),
1088                    ) {
1089                        return crate::features::commits::commands::search_diff_file(
1090                            repo_path, oid, file_path,
1091                        );
1092                    }
1093                }
1094                Task::none()
1095            }
1096
1097            Message::SearchFileDiffLoaded(result) => {
1098                match result {
1099                    Ok(diff) => {
1100                        self.search_diff_content = vec![diff.clone()];
1101                    }
1102                    Err(e) => {
1103                        self.active_tab_mut().error_message =
1104                            Some(format!("Failed to load file diff: {e}"));
1105                    }
1106                }
1107                Task::none()
1108            }
1109
1110            Message::DiffSelectedFiles => {
1111                if self.search_diff_selected.is_empty() {
1112                    return Task::none();
1113                }
1114                let file_paths: Vec<String> = self
1115                    .search_diff_selected
1116                    .iter()
1117                    .filter_map(|&i| self.search_diff_files.get(i))
1118                    .map(|f| f.display_path().to_string())
1119                    .collect();
1120                if let (Some(oid), Some(repo_path)) = (
1121                    self.search_diff_oid.clone(),
1122                    self.active_tab().repo_path.clone(),
1123                ) {
1124                    return crate::features::commits::commands::search_diff_multi_files(
1125                        repo_path, oid, file_paths,
1126                    );
1127                }
1128                Task::none()
1129            }
1130
1131            Message::SearchMultiDiffLoaded(result) => {
1132                match result {
1133                    Ok(diffs) => {
1134                        self.search_diff_content = diffs.clone();
1135                    }
1136                    Err(e) => {
1137                        self.active_tab_mut().error_message =
1138                            Some(format!("Failed to load diffs: {e}"));
1139                    }
1140                }
1141                Task::none()
1142            }
1143
1144            Message::SearchDiffBack => {
1145                self.search_diff_content.clear();
1146                Task::none()
1147            }
1148
1149            Message::FileSystemChanged => {
1150                // Don't refresh while the active tab is performing a full
1151                // load (e.g. opening a new repo).  Background pagination
1152                // (is_loading_more_commits) is NOT checked here — the
1153                // "don't shrink" guard in apply_payload already prevents
1154                // a stale refresh from overwriting a larger paginated list.
1155                // Checking is_loading_more_commits here would permanently
1156                // block refreshes if the flag got stuck (e.g. user switched
1157                // tabs while pagination was in flight).
1158                if self.has_repo() && !self.active_tab().is_loading {
1159                    return self.refresh_active_tab();
1160                }
1161                Task::none()
1162            }
1163
1164            Message::OpenInEditor(path) => {
1165                self.active_tab_mut().context_menu = None;
1166                if matches!(self.editor, gitkraft_core::Editor::None) {
1167                    self.active_tab_mut().status_message = Some(
1168                        "No editor configured — select one from the editor dropdown in the toolbar"
1169                            .into(),
1170                    );
1171                    return Task::none();
1172                }
1173                if let Some(repo_path) = self.active_tab().repo_path.as_ref() {
1174                    let full_path = repo_path.join(path);
1175                    match self.editor.open_file(&full_path) {
1176                        Ok(()) => {
1177                            self.active_tab_mut().status_message =
1178                                Some(format!("Opened in {}", self.editor));
1179                        }
1180                        Err(e) => {
1181                            self.active_tab_mut().error_message =
1182                                Some(format!("Failed to open editor: {e}"));
1183                        }
1184                    }
1185                }
1186                Task::none()
1187            }
1188
1189            Message::OpenInDefaultProgram(path) => {
1190                self.active_tab_mut().context_menu = None;
1191                if let Some(repo_path) = self.active_tab().repo_path.as_ref() {
1192                    let full_path = repo_path.join(path);
1193                    if let Err(e) = gitkraft_core::open_file_default(&full_path) {
1194                        self.active_tab_mut().error_message =
1195                            Some(format!("Failed to open file: {e}"));
1196                    }
1197                }
1198                Task::none()
1199            }
1200
1201            Message::OpenFilesInEditor(paths) => {
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.clone() {
1211                    let mut last_err: Option<String> = None;
1212                    for p in paths.iter() {
1213                        let full_path = repo_path.join(p);
1214                        if let Err(e) = self.editor.open_file(&full_path) {
1215                            last_err = Some(format!("{p}: {e}"));
1216                        }
1217                    }
1218                    if let Some(e) = last_err {
1219                        self.active_tab_mut().error_message =
1220                            Some(format!("Failed to open in editor: {e}"));
1221                    } else {
1222                        self.active_tab_mut().status_message =
1223                            Some(format!("Opened {} file(s) in {}", paths.len(), self.editor));
1224                    }
1225                }
1226                Task::none()
1227            }
1228
1229            Message::OpenFilesInDefaultProgram(paths) => {
1230                self.active_tab_mut().context_menu = None;
1231                if let Some(repo_path) = self.active_tab().repo_path.clone() {
1232                    for p in paths.iter() {
1233                        let full_path = repo_path.join(p);
1234                        if let Err(e) = gitkraft_core::open_file_default(&full_path) {
1235                            self.active_tab_mut().error_message =
1236                                Some(format!("Failed to open file: {e}"));
1237                            break;
1238                        }
1239                    }
1240                }
1241                Task::none()
1242            }
1243
1244            Message::PreviewFile(path) => {
1245                self.active_tab_mut().context_menu = None;
1246                if let Some(repo_path) = self.active_tab().repo_path.clone() {
1247                    let full_path = repo_path.join(path);
1248                    let file_path = full_path.display().to_string();
1249                    git_task!(
1250                        Message::FilePreviewLoaded,
1251                        std::fs::read_to_string(&full_path)
1252                            .map(|content| (file_path, content))
1253                            .str_err()
1254                    )
1255                } else {
1256                    Task::none()
1257                }
1258            }
1259
1260            Message::FilePreviewLoaded(result) => {
1261                match result {
1262                    Ok((file_path, content)) => {
1263                        self.preview_editor =
1264                            Some(iced::widget::text_editor::Content::with_text(content));
1265                        self.preview_path = Some(file_path.clone());
1266                        // Clear the diff view so the preview takes over
1267                        self.active_tab_mut().selected_diff = None;
1268                    }
1269                    Err(e) => {
1270                        self.active_tab_mut().error_message =
1271                            Some(format!("Cannot preview file: {e}"));
1272                    }
1273                }
1274                Task::none()
1275            }
1276
1277            Message::ShowInFolder(path) => {
1278                self.active_tab_mut().context_menu = None;
1279                if let Some(repo_path) = self.active_tab().repo_path.as_ref() {
1280                    let full_path = repo_path.join(path);
1281                    if let Err(e) = gitkraft_core::show_in_folder(&full_path) {
1282                        self.active_tab_mut().error_message =
1283                            Some(format!("Failed to show in folder: {e}"));
1284                    }
1285                }
1286                Task::none()
1287            }
1288
1289            // ── File history overlay ──────────────────────────────────────────────
1290            Message::ViewFileHistory(path) => {
1291                let path = path.clone();
1292                if let Some(repo_path) = self.active_tab().repo_path.clone() {
1293                    let tab = self.active_tab_mut();
1294                    tab.blame_path = None; // close blame if open
1295                    tab.file_history_path = Some(path.clone());
1296                    tab.file_history_commits.clear();
1297                    tab.file_history_scroll = 0.0;
1298                    tab.context_menu = None;
1299                    tab.status_message = Some(format!(
1300                        "Loading history for {}…",
1301                        gitkraft_core::path_basename(&path)
1302                    ));
1303                    crate::features::repo::commands::file_history_async(repo_path, path)
1304                } else {
1305                    Task::none()
1306                }
1307            }
1308
1309            Message::FileHistoryLoaded(result) => {
1310                match result {
1311                    Ok((path, commits)) => {
1312                        let tab = self.active_tab_mut();
1313                        tab.file_history_path = Some(path.clone());
1314                        tab.file_history_commits = commits.clone();
1315                        tab.status_message = Some(format!(
1316                            "{} commits touch {}",
1317                            commits.len(),
1318                            gitkraft_core::path_basename(path)
1319                        ));
1320                    }
1321                    Err(e) => {
1322                        let tab = self.active_tab_mut();
1323                        tab.file_history_path = None;
1324                        tab.error_message = Some(format!("File history failed: {e}"));
1325                    }
1326                }
1327                Task::none()
1328            }
1329
1330            Message::CloseFileHistory => {
1331                let tab = self.active_tab_mut();
1332                tab.file_history_path = None;
1333                tab.file_history_commits = Vec::new();
1334                tab.file_history_scroll = 0.0;
1335                Task::none()
1336            }
1337
1338            Message::FileHistoryScrolled(y) => {
1339                self.active_tab_mut().file_history_scroll = *y;
1340                Task::none()
1341            }
1342
1343            Message::SelectFileHistoryCommit(oid) => {
1344                let oid = oid.clone();
1345                let repo_path = self.active_tab().repo_path.clone();
1346                {
1347                    let tab = self.active_tab_mut();
1348                    tab.file_history_path = None;
1349                    tab.file_history_commits.clear();
1350                    tab.selected_commit_oid = Some(oid.clone());
1351                    tab.commit_files.clear();
1352                    tab.selected_diff = None;
1353                    tab.show_commit_detail = true;
1354                }
1355                if let Some(path) = repo_path {
1356                    crate::features::commits::commands::load_commit_file_list(path, oid)
1357                } else {
1358                    Task::none()
1359                }
1360            }
1361
1362            // ── Blame overlay ─────────────────────────────────────────────────────
1363            Message::ViewFileBlame(path) => {
1364                let path = path.clone();
1365                if let Some(repo_path) = self.active_tab().repo_path.clone() {
1366                    let tab = self.active_tab_mut();
1367                    tab.file_history_path = None; // close history if open
1368                    tab.blame_path = Some(path.clone());
1369                    tab.blame_lines.clear();
1370                    tab.blame_scroll = 0.0;
1371                    tab.context_menu = None;
1372                    tab.status_message = Some(format!(
1373                        "Loading blame for {}…",
1374                        gitkraft_core::path_basename(&path)
1375                    ));
1376                    crate::features::repo::commands::blame_file_async(repo_path, path)
1377                } else {
1378                    Task::none()
1379                }
1380            }
1381
1382            Message::FileBlameLoaded(result) => {
1383                match result {
1384                    Ok((path, lines)) => {
1385                        let tab = self.active_tab_mut();
1386                        tab.blame_path = Some(path.clone());
1387                        tab.blame_lines = lines.clone();
1388                        tab.status_message = Some(format!(
1389                            "Blame: {} ({} lines)",
1390                            gitkraft_core::path_basename(path),
1391                            lines.len()
1392                        ));
1393                    }
1394                    Err(e) => {
1395                        let tab = self.active_tab_mut();
1396                        tab.blame_path = None;
1397                        tab.error_message = Some(format!("Blame failed: {e}"));
1398                    }
1399                }
1400                Task::none()
1401            }
1402
1403            Message::CloseFileBlame => {
1404                let tab = self.active_tab_mut();
1405                tab.blame_path = None;
1406                tab.blame_lines = Vec::new();
1407                tab.blame_scroll = 0.0;
1408                // Also dismiss context menu and search overlay on Escape.
1409                tab.context_menu = None;
1410                if self.search_visible {
1411                    self.search_visible = false;
1412                    self.search_query = String::new();
1413                    self.search_results = Vec::new();
1414                    self.search_selected = None;
1415                    self.search_diff_files = Vec::new();
1416                    self.search_diff_selected = HashSet::new();
1417                    self.search_diff_content = Vec::new();
1418                    self.search_diff_oid = None;
1419                }
1420                Task::none()
1421            }
1422
1423            Message::BlameScrolled(y) => {
1424                self.active_tab_mut().blame_scroll = *y;
1425                Task::none()
1426            }
1427
1428            // ── File deletion ─────────────────────────────────────────────────────
1429            Message::DeleteFile(path) => {
1430                let tab = self.active_tab_mut();
1431                tab.context_menu = None;
1432                tab.pending_delete_file = Some(path.clone());
1433                tab.status_message = Some(format!(
1434                    "Delete '{}' — press Confirm to delete permanently",
1435                    gitkraft_core::path_basename(path)
1436                ));
1437                Task::none()
1438            }
1439
1440            Message::ConfirmDeleteFile => {
1441                let path = self.active_tab().pending_delete_file.clone();
1442                let repo_path = self.active_tab().repo_path.clone();
1443                if let (Some(file_path), Some(repo_path)) = (path, repo_path) {
1444                    let tab = self.active_tab_mut();
1445                    tab.pending_delete_file = None;
1446                    tab.is_loading = true;
1447                    tab.status_message = Some(format!(
1448                        "Deleting '{}'…",
1449                        gitkraft_core::path_basename(&file_path)
1450                    ));
1451                    crate::features::repo::commands::delete_file_async(repo_path, file_path)
1452                } else {
1453                    Task::none()
1454                }
1455            }
1456
1457            Message::CancelDeleteFile => {
1458                let tab = self.active_tab_mut();
1459                tab.pending_delete_file = None;
1460                tab.status_message = None;
1461                Task::none()
1462            }
1463
1464            Message::PreviewEditorAction(action) => {
1465                if let Some(ref mut content) = self.preview_editor {
1466                    content.perform(action.clone());
1467                }
1468                Task::none()
1469            }
1470
1471            Message::ClosePreview => {
1472                clear_preview!(self);
1473                Task::none()
1474            }
1475
1476            Message::HoverCommit(idx) => {
1477                self.active_tab_mut().hovered_commit = *idx;
1478                Task::none()
1479            }
1480
1481            Message::Noop => Task::none(),
1482
1483            Message::CherryPickCommits(oids) => {
1484                let oids = oids.clone();
1485                with_repo!(
1486                    self,
1487                    dismiss,
1488                    loading,
1489                    format!("Cherry-picking {} commit(s)…", oids.len()),
1490                    |path| crate::features::repo::commands::cherry_pick_commits_async(path, oids)
1491                )
1492            }
1493
1494            Message::RevertCommits(oids) => {
1495                let oids = oids.clone();
1496                with_repo!(
1497                    self,
1498                    dismiss,
1499                    loading,
1500                    format!("Reverting {} commit(s)…", oids.len()),
1501                    |path| crate::features::repo::commands::revert_commits_async(path, oids)
1502                )
1503            }
1504        }
1505    }
1506}