Skip to main content

gitkraft_gui/
update.rs

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