Skip to main content

gitkraft_gui/
update.rs

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