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