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