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