Skip to main content

gitkraft_gui/
update.rs

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