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                        }
398                    }
399                }
400
401                if let Some(target_h) = self.dragging_h {
402                    if !self.drag_initialized_h {
403                        self.drag_start_y = *y;
404                        self.drag_initialized_h = true;
405                    } else {
406                        let dy = *y - self.drag_start_y;
407                        self.drag_start_y = *y;
408
409                        match target_h {
410                            DragTargetH::StagingTop => {
411                                // Dragging up → larger staging area (subtract dy).
412                                self.staging_height =
413                                    (self.staging_height - dy).clamp(100.0, 600.0);
414                            }
415                        }
416                    }
417                }
418
419                Task::none()
420            }
421
422            Message::PaneDragEnd => {
423                self.dragging = None;
424                self.dragging_h = None;
425                self.drag_initialized = false;
426                self.drag_initialized_h = false;
427                crate::features::repo::commands::save_layout_async(self.current_layout())
428            }
429
430            // ── Context menu lifecycle ────────────────────────────────────────────────
431            Message::OpenBranchContextMenu(name, local_index, is_current) => {
432                let pos = (self.cursor_pos.x, self.cursor_pos.y);
433                let tab = self.active_tab_mut();
434                tab.context_menu_pos = pos;
435                tab.context_menu = Some(crate::state::ContextMenu::Branch {
436                    name: name.clone(),
437                    is_current: *is_current,
438                    local_index: *local_index,
439                });
440                Task::none()
441            }
442
443            Message::OpenRemoteBranchContextMenu(name) => {
444                let pos = (self.cursor_pos.x, self.cursor_pos.y);
445                let tab = self.active_tab_mut();
446                tab.context_menu_pos = pos;
447                tab.context_menu =
448                    Some(crate::state::ContextMenu::RemoteBranch { name: name.clone() });
449                Task::none()
450            }
451
452            Message::OpenCommitFileContextMenu(oid, file_path) => {
453                let pos = (self.cursor_pos.x, self.cursor_pos.y);
454                let tab = self.active_tab_mut();
455                tab.context_menu_pos = pos;
456                tab.context_menu = Some(crate::state::ContextMenu::CommitFile {
457                    oid: oid.clone(),
458                    file_path: file_path.clone(),
459                });
460                Task::none()
461            }
462
463            Message::OpenStashContextMenu(index) => {
464                let index = *index;
465                let pos = (self.cursor_pos.x, self.cursor_pos.y);
466                let tab = self.active_tab_mut();
467                tab.context_menu_pos = pos;
468                tab.context_menu = Some(crate::state::ContextMenu::Stash { index });
469                Task::none()
470            }
471
472            Message::OpenUnstagedFileContextMenu(path) => {
473                let pos = (self.cursor_pos.x, self.cursor_pos.y);
474                let tab = self.active_tab_mut();
475                tab.context_menu_pos = pos;
476                tab.context_menu =
477                    Some(crate::state::ContextMenu::UnstagedFile { path: path.clone() });
478                Task::none()
479            }
480
481            Message::OpenStagedFileContextMenu(path) => {
482                let pos = (self.cursor_pos.x, self.cursor_pos.y);
483                let tab = self.active_tab_mut();
484                tab.context_menu_pos = pos;
485                tab.context_menu =
486                    Some(crate::state::ContextMenu::StagedFile { path: path.clone() });
487                Task::none()
488            }
489
490            Message::OpenCommitContextMenu(idx) => {
491                let oid = self.active_tab().commits.get(*idx).map(|c| c.oid.clone());
492                let pos = (self.cursor_pos.x, self.cursor_pos.y);
493                if let Some(oid) = oid {
494                    let tab = self.active_tab_mut();
495                    tab.context_menu_pos = pos;
496                    tab.context_menu = Some(crate::state::ContextMenu::Commit { index: *idx, oid });
497                }
498                Task::none()
499            }
500
501            Message::OpenSearchResultContextMenu(idx) => {
502                if let Some(commit) = self.search_results.get(*idx) {
503                    let oid = commit.oid.clone();
504                    let pos = (self.cursor_pos.x, self.cursor_pos.y);
505                    let tab = self.active_tab_mut();
506                    tab.context_menu_pos = pos;
507                    tab.context_menu = Some(crate::state::ContextMenu::Commit { index: *idx, oid });
508                }
509                Task::none()
510            }
511
512            Message::CloseContextMenu => {
513                self.active_tab_mut().context_menu = None;
514                Task::none()
515            }
516
517            // ── Inline branch rename ──────────────────────────────────────────────────
518            Message::BeginRenameBranch(name) => {
519                let tab = self.active_tab_mut();
520                tab.context_menu = None;
521                tab.rename_branch_input = name.clone();
522                tab.rename_branch_target = Some(name.clone());
523                Task::none()
524            }
525
526            Message::RenameBranchInputChanged(s) => {
527                self.active_tab_mut().rename_branch_input = s.clone();
528                Task::none()
529            }
530
531            Message::CancelRename => {
532                let tab = self.active_tab_mut();
533                tab.rename_branch_target = None;
534                tab.rename_branch_input.clear();
535                Task::none()
536            }
537
538            Message::ConfirmRenameBranch => {
539                let (original, new_name, path) = {
540                    let tab = self.active_tab();
541                    (
542                        tab.rename_branch_target.clone(),
543                        tab.rename_branch_input.trim().to_string(),
544                        tab.repo_path.clone(),
545                    )
546                };
547                if let (Some(orig), false) = (&original, new_name.is_empty()) {
548                    if *orig != new_name {
549                        if let Some(path) = path {
550                            let orig = orig.clone();
551                            {
552                                let tab = self.active_tab_mut();
553                                tab.rename_branch_target = None;
554                                tab.rename_branch_input.clear();
555                                tab.is_loading = true;
556                                tab.status_message =
557                                    Some(format!("Renaming '{orig}' → '{new_name}'…"));
558                            }
559                            return crate::features::repo::commands::rename_branch_async(
560                                path, orig, new_name,
561                            );
562                        }
563                    }
564                }
565                self.active_tab_mut().rename_branch_target = None;
566                Task::none()
567            }
568
569            // ── Branch context menu actions ───────────────────────────────────────────
570            Message::PushBranch(name) => {
571                let name = name.clone();
572                let remote = self
573                    .active_tab()
574                    .remotes
575                    .first()
576                    .map(|r| r.name.clone())
577                    .unwrap_or_else(|| "origin".to_string());
578                self.active_tab_mut().context_menu = None;
579                with_repo!(
580                    self,
581                    loading,
582                    format!("Pushing '{name}' to {remote}…"),
583                    |path| crate::features::repo::commands::push_branch_async(path, name, remote)
584                )
585            }
586
587            Message::PullBranch(_name) => {
588                let remote = self
589                    .active_tab()
590                    .remotes
591                    .first()
592                    .map(|r| r.name.clone())
593                    .unwrap_or_else(|| "origin".to_string());
594                self.active_tab_mut().context_menu = None;
595                with_repo!(
596                    self,
597                    loading,
598                    format!("Pulling from {remote} (rebase)…"),
599                    |path| crate::features::repo::commands::pull_rebase_async(path, remote)
600                )
601            }
602
603            Message::RebaseOnto(target) => {
604                let target = target.clone();
605                self.active_tab_mut().context_menu = None;
606                with_repo!(
607                    self,
608                    loading,
609                    format!("Rebasing onto '{target}'…"),
610                    |path| crate::features::repo::commands::rebase_onto_async(path, target)
611                )
612            }
613
614            Message::MergeBranch(name) => {
615                let name = name.clone();
616                self.active_tab_mut().context_menu = None;
617                with_repo!(
618                    self,
619                    loading,
620                    format!("Merging '{name}' into current branch…"),
621                    |path| crate::features::repo::commands::merge_branch_async(path, name)
622                )
623            }
624
625            Message::CheckoutRemoteBranch(name) => {
626                let name = name.clone();
627                self.active_tab_mut().context_menu = None;
628                with_repo!(self, loading, format!("Checking out '{name}'…"), |path| {
629                    crate::features::repo::commands::checkout_remote_branch_async(path, name)
630                })
631            }
632
633            Message::DeleteRemoteBranch(name) => {
634                let name = name.clone();
635                self.active_tab_mut().context_menu = None;
636                with_repo!(
637                    self,
638                    loading,
639                    format!("Deleting remote branch '{name}'…"),
640                    |path| crate::features::repo::commands::delete_remote_branch_async(path, name)
641                )
642            }
643
644            Message::BeginCreateTag(oid, annotated) => {
645                let tab = self.active_tab_mut();
646                tab.context_menu = None;
647                tab.create_tag_target_oid = Some(oid.clone());
648                tab.create_tag_annotated = *annotated;
649                tab.create_tag_name.clear();
650                tab.create_tag_message.clear();
651                Task::none()
652            }
653
654            Message::TagNameChanged(s) => {
655                self.active_tab_mut().create_tag_name = s.clone();
656                Task::none()
657            }
658
659            Message::TagMessageChanged(s) => {
660                self.active_tab_mut().create_tag_message = s.clone();
661                Task::none()
662            }
663
664            Message::ConfirmCreateTag => {
665                let (oid, name, message, annotated, path) = {
666                    let tab = self.active_tab();
667                    (
668                        tab.create_tag_target_oid.clone(),
669                        tab.create_tag_name.trim().to_string(),
670                        tab.create_tag_message.trim().to_string(),
671                        tab.create_tag_annotated,
672                        tab.repo_path.clone(),
673                    )
674                };
675                if let (Some(oid), false) = (&oid, name.is_empty()) {
676                    if let Some(path) = path {
677                        let oid = oid.clone();
678                        {
679                            let tab = self.active_tab_mut();
680                            tab.create_tag_target_oid = None;
681                            tab.create_tag_name.clear();
682                            tab.create_tag_message.clear();
683                            tab.is_loading = true;
684                            tab.status_message = Some(format!("Creating tag '{name}'…"));
685                        }
686                        return if annotated {
687                            crate::features::repo::commands::create_annotated_tag_async(
688                                path, name, message, oid,
689                            )
690                        } else {
691                            crate::features::repo::commands::create_tag_async(path, name, oid)
692                        };
693                    }
694                }
695                Task::none()
696            }
697
698            Message::CancelCreateTag => {
699                let tab = self.active_tab_mut();
700                tab.create_tag_target_oid = None;
701                tab.create_tag_name.clear();
702                tab.create_tag_message.clear();
703                Task::none()
704            }
705
706            Message::CherryPickCommit(oid) => {
707                let oid = oid.clone();
708                let short = gitkraft_core::utils::short_oid_str(&oid).to_string();
709                self.active_tab_mut().context_menu = None;
710                with_repo!(self, loading, format!("Cherry-picking {short}…"), |path| {
711                    crate::features::repo::commands::cherry_pick_async(path, oid)
712                })
713            }
714
715            Message::BeginCreateBranchAtCommit(oid) => {
716                let tab = self.active_tab_mut();
717                tab.context_menu = None;
718                tab.create_branch_at_oid = Some(oid.clone());
719                tab.new_branch_name.clear();
720                Task::none()
721            }
722
723            Message::ConfirmCreateBranchAtCommit => {
724                let (oid, name, path) = {
725                    let tab = self.active_tab();
726                    (
727                        tab.create_branch_at_oid.clone(),
728                        tab.new_branch_name.trim().to_string(),
729                        tab.repo_path.clone(),
730                    )
731                };
732                if let (Some(oid), false) = (&oid, name.is_empty()) {
733                    if let Some(path) = path {
734                        let oid = oid.clone();
735                        {
736                            let tab = self.active_tab_mut();
737                            tab.create_branch_at_oid = None;
738                            tab.new_branch_name.clear();
739                            tab.is_loading = true;
740                            tab.status_message = Some(format!("Creating branch '{name}'…"));
741                        }
742                        return crate::features::repo::commands::create_branch_at_commit_async(
743                            path, name, oid,
744                        );
745                    }
746                }
747                Task::none()
748            }
749
750            Message::CancelCreateBranchAtCommit => {
751                let tab = self.active_tab_mut();
752                tab.create_branch_at_oid = None;
753                tab.new_branch_name.clear();
754                Task::none()
755            }
756
757            // ── Commit context menu actions ───────────────────────────────────────
758            Message::ExecuteCommitAction(oid, action) => {
759                let oid = oid.clone();
760                let action = action.clone();
761                let label = action.label();
762                self.active_tab_mut().context_menu = None;
763                with_repo!(self, loading, format!("{label}…"), |path| {
764                    crate::features::repo::commands::execute_commit_action_async(path, oid, action)
765                })
766            }
767
768            Message::CheckoutCommitDetached(oid) => {
769                let oid = oid.clone();
770                let short = gitkraft_core::utils::short_oid_str(&oid).to_string();
771                self.active_tab_mut().context_menu = None;
772                with_repo!(self, loading, format!("Checking out {short}…"), |path| {
773                    crate::features::repo::commands::checkout_commit_async(path, oid)
774                })
775            }
776
777            Message::RebaseOntoCommit(oid) => {
778                let oid = oid.clone();
779                let short = gitkraft_core::utils::short_oid_str(&oid).to_string();
780                self.active_tab_mut().context_menu = None;
781                with_repo!(self, loading, format!("Rebasing onto {short}…"), |path| {
782                    crate::features::repo::commands::rebase_onto_async(path, oid)
783                })
784            }
785
786            Message::RevertCommit(oid) => {
787                let oid = oid.clone();
788                let short = gitkraft_core::utils::short_oid_str(&oid).to_string();
789                self.active_tab_mut().context_menu = None;
790                with_repo!(self, loading, format!("Reverting {short}…"), |path| {
791                    crate::features::repo::commands::revert_commit_async(path, oid)
792                })
793            }
794
795            Message::ResetSoft(ref oid)
796            | Message::ResetMixed(ref oid)
797            | Message::ResetHard(ref oid) => {
798                let mode = match &message {
799                    Message::ResetSoft(_) => "soft",
800                    Message::ResetMixed(_) => "mixed",
801                    Message::ResetHard(_) => "hard",
802                    _ => unreachable!(),
803                };
804                let oid = oid.clone();
805                let short = gitkraft_core::utils::short_oid_str(&oid).to_string();
806                self.active_tab_mut().context_menu = None;
807                with_repo!(
808                    self,
809                    loading,
810                    format!("Resetting ({mode}) to {short}…"),
811                    |path| crate::features::repo::commands::reset_to_commit_async(
812                        path,
813                        oid,
814                        mode.to_string()
815                    )
816                )
817            }
818
819            // ── Shared ───────────────────────────────────────────────────────────────
820            Message::CopyText(text) => {
821                self.active_tab_mut().context_menu = None;
822                iced::clipboard::write(text.clone())
823            }
824
825            // ── Persistence / misc ────────────────────────────────────────
826            Message::ThemeChanged(index) => {
827                self.current_theme_index = *index;
828                // Persist the selected theme name on a background thread.
829                let name = gitkraft_core::THEME_NAMES
830                    .get(*index)
831                    .copied()
832                    .unwrap_or("Default");
833                crate::features::repo::commands::save_theme_async(name.to_string())
834            }
835
836            Message::ThemeSaved(_result) => {
837                // Fire-and-forget — errors are silently ignored.
838                Task::none()
839            }
840
841            Message::EditorChanged(editor) => {
842                self.editor = editor.clone();
843                self.active_tab_mut().status_message =
844                    Some(format!("Editor set to {}", self.editor));
845                // Persist the editor choice
846                let name = self.editor.display_name().to_string();
847                crate::features::repo::commands::save_editor_async(name)
848            }
849
850            Message::EditorSaved(_result) => {
851                // Fire-and-forget — errors are silently ignored.
852                Task::none()
853            }
854
855            Message::LayoutSaved(_result) => {
856                // Fire-and-forget — errors are silently ignored.
857                Task::none()
858            }
859
860            Message::SessionSaved(_) => {
861                // Fire-and-forget — errors are silently ignored.
862                Task::none()
863            }
864
865            Message::LayoutLoaded(result) => {
866                if let Ok(Some(layout)) = result {
867                    if let Some(w) = layout.sidebar_width {
868                        self.sidebar_width = w;
869                    }
870                    if let Some(w) = layout.commit_log_width {
871                        self.commit_log_width = w;
872                    }
873                    if let Some(h) = layout.staging_height {
874                        self.staging_height = h;
875                    }
876                    if let Some(w) = layout.diff_file_list_width {
877                        self.diff_file_list_width = w;
878                    }
879                    if let Some(expanded) = layout.sidebar_expanded {
880                        self.sidebar_expanded = expanded;
881                    }
882                    if let Some(scale) = layout.ui_scale {
883                        self.ui_scale = scale.clamp(0.5, 2.0);
884                    }
885                }
886                Task::none()
887            }
888
889            // ── Search ────────────────────────────────────────────────────
890            Message::ToggleSearch => {
891                self.search_visible = !self.search_visible;
892                if !self.search_visible {
893                    self.search_query.clear();
894                    self.search_results.clear();
895                    self.search_selected = None;
896                    self.search_diff_files.clear();
897                    self.search_diff_selected.clear();
898                    self.search_diff_content.clear();
899                    self.search_diff_oid = None;
900                    Task::none()
901                } else {
902                    iced::widget::operation::focus_next()
903                }
904            }
905
906            Message::SearchQueryChanged(query) => {
907                let query = query.clone();
908                self.search_query = query.clone();
909                if query.trim().len() >= 2 {
910                    if let Some(path) = self.active_tab().repo_path.clone() {
911                        return crate::features::commits::commands::search_commits(path, query);
912                    }
913                } else {
914                    self.search_results.clear();
915                    self.search_selected = None;
916                }
917                Task::none()
918            }
919
920            Message::SearchResultsLoaded(result) => {
921                match result {
922                    Ok(results) => {
923                        self.search_results = results.clone();
924                        self.search_selected = if self.search_results.is_empty() {
925                            None
926                        } else {
927                            Some(0)
928                        };
929                    }
930                    Err(e) => {
931                        self.search_results.clear();
932                        self.active_tab_mut().error_message = Some(format!("Search failed: {e}"));
933                    }
934                }
935                Task::none()
936            }
937
938            Message::SelectSearchResult(index) => {
939                let index = *index;
940                if index < self.search_results.len() {
941                    self.search_selected = Some(index);
942                }
943                Task::none()
944            }
945
946            Message::ConfirmSearchResult => {
947                if let Some(idx) = self.search_selected {
948                    if let Some(commit) = self.search_results.get(idx).cloned() {
949                        let oid = commit.oid.clone();
950                        // Keep search open — load the file list for commit vs working tree
951                        self.search_diff_oid = Some(oid.clone());
952                        self.search_diff_files.clear();
953                        self.search_diff_selected.clear();
954                        self.search_diff_content.clear();
955
956                        if let Some(path) = self.active_tab().repo_path.clone() {
957                            return crate::features::commits::commands::search_diff_file_list(
958                                path, oid,
959                            );
960                        }
961                    }
962                }
963                Task::none()
964            }
965
966            Message::SearchDiffFilesLoaded(result) => {
967                match result {
968                    Ok(files) => {
969                        self.search_diff_files = files.clone();
970                        self.search_diff_selected.clear();
971                        self.search_diff_content.clear();
972                    }
973                    Err(e) => {
974                        self.active_tab_mut().error_message =
975                            Some(format!("Failed to load diff files: {e}"));
976                    }
977                }
978                Task::none()
979            }
980
981            Message::ToggleSearchDiffFile(index) => {
982                let index = *index;
983                if self.search_diff_selected.contains(&index) {
984                    self.search_diff_selected.remove(&index);
985                } else {
986                    self.search_diff_selected.insert(index);
987                }
988                Task::none()
989            }
990
991            Message::ToggleSearchDiffSelectAll => {
992                if self.search_diff_selected.len() == self.search_diff_files.len() {
993                    self.search_diff_selected.clear();
994                } else {
995                    self.search_diff_selected = (0..self.search_diff_files.len()).collect();
996                }
997                Task::none()
998            }
999
1000            Message::ViewSearchDiffFile(index) => {
1001                let index = *index;
1002                if let Some(file) = self.search_diff_files.get(index) {
1003                    let file_path = file.display_path().to_string();
1004                    if let (Some(oid), Some(repo_path)) = (
1005                        self.search_diff_oid.clone(),
1006                        self.active_tab().repo_path.clone(),
1007                    ) {
1008                        return crate::features::commits::commands::search_diff_file(
1009                            repo_path, oid, file_path,
1010                        );
1011                    }
1012                }
1013                Task::none()
1014            }
1015
1016            Message::SearchFileDiffLoaded(result) => {
1017                match result {
1018                    Ok(diff) => {
1019                        self.search_diff_content = vec![diff.clone()];
1020                    }
1021                    Err(e) => {
1022                        self.active_tab_mut().error_message =
1023                            Some(format!("Failed to load file diff: {e}"));
1024                    }
1025                }
1026                Task::none()
1027            }
1028
1029            Message::DiffSelectedFiles => {
1030                if self.search_diff_selected.is_empty() {
1031                    return Task::none();
1032                }
1033                let file_paths: Vec<String> = self
1034                    .search_diff_selected
1035                    .iter()
1036                    .filter_map(|&i| self.search_diff_files.get(i))
1037                    .map(|f| f.display_path().to_string())
1038                    .collect();
1039                if let (Some(oid), Some(repo_path)) = (
1040                    self.search_diff_oid.clone(),
1041                    self.active_tab().repo_path.clone(),
1042                ) {
1043                    return crate::features::commits::commands::search_diff_multi_files(
1044                        repo_path, oid, file_paths,
1045                    );
1046                }
1047                Task::none()
1048            }
1049
1050            Message::SearchMultiDiffLoaded(result) => {
1051                match result {
1052                    Ok(diffs) => {
1053                        self.search_diff_content = diffs.clone();
1054                    }
1055                    Err(e) => {
1056                        self.active_tab_mut().error_message =
1057                            Some(format!("Failed to load diffs: {e}"));
1058                    }
1059                }
1060                Task::none()
1061            }
1062
1063            Message::SearchDiffBack => {
1064                self.search_diff_content.clear();
1065                Task::none()
1066            }
1067
1068            Message::FileSystemChanged => {
1069                if self.has_repo() && !self.active_tab().is_loading {
1070                    return self.refresh_active_tab();
1071                }
1072                Task::none()
1073            }
1074
1075            Message::OpenInEditor(path) => {
1076                self.active_tab_mut().context_menu = None;
1077                if matches!(self.editor, gitkraft_core::Editor::None) {
1078                    self.active_tab_mut().status_message = Some(
1079                        "No editor configured — select one from the editor dropdown in the toolbar"
1080                            .into(),
1081                    );
1082                    return Task::none();
1083                }
1084                if let Some(repo_path) = self.active_tab().repo_path.as_ref() {
1085                    let full_path = repo_path.join(path);
1086                    match self.editor.open_file(&full_path) {
1087                        Ok(()) => {
1088                            self.active_tab_mut().status_message =
1089                                Some(format!("Opened in {}", self.editor));
1090                        }
1091                        Err(e) => {
1092                            self.active_tab_mut().error_message =
1093                                Some(format!("Failed to open editor: {e}"));
1094                        }
1095                    }
1096                }
1097                Task::none()
1098            }
1099
1100            Message::OpenInDefaultProgram(path) => {
1101                self.active_tab_mut().context_menu = None;
1102                if let Some(repo_path) = self.active_tab().repo_path.as_ref() {
1103                    let full_path = repo_path.join(path);
1104                    if let Err(e) = gitkraft_core::open_file_default(&full_path) {
1105                        self.active_tab_mut().error_message =
1106                            Some(format!("Failed to open file: {e}"));
1107                    }
1108                }
1109                Task::none()
1110            }
1111
1112            Message::ShowInFolder(path) => {
1113                self.active_tab_mut().context_menu = None;
1114                if let Some(repo_path) = self.active_tab().repo_path.as_ref() {
1115                    let full_path = repo_path.join(path);
1116                    if let Err(e) = gitkraft_core::show_in_folder(&full_path) {
1117                        self.active_tab_mut().error_message =
1118                            Some(format!("Failed to show in folder: {e}"));
1119                    }
1120                }
1121                Task::none()
1122            }
1123
1124            // ── File history overlay ──────────────────────────────────────────────
1125            Message::ViewFileHistory(path) => {
1126                let path = path.clone();
1127                if let Some(repo_path) = self.active_tab().repo_path.clone() {
1128                    let tab = self.active_tab_mut();
1129                    tab.blame_path = None; // close blame if open
1130                    tab.file_history_path = Some(path.clone());
1131                    tab.file_history_commits.clear();
1132                    tab.file_history_scroll = 0.0;
1133                    tab.context_menu = None;
1134                    tab.status_message = Some(format!(
1135                        "Loading history for {}…",
1136                        path.rsplit('/').next().unwrap_or(&path)
1137                    ));
1138                    crate::features::repo::commands::file_history_async(repo_path, path)
1139                } else {
1140                    Task::none()
1141                }
1142            }
1143
1144            Message::FileHistoryLoaded(result) => {
1145                match result {
1146                    Ok((path, commits)) => {
1147                        let tab = self.active_tab_mut();
1148                        tab.file_history_path = Some(path.clone());
1149                        tab.file_history_commits = commits.clone();
1150                        tab.status_message = Some(format!(
1151                            "{} commits touch {}",
1152                            commits.len(),
1153                            path.rsplit('/').next().unwrap_or(path)
1154                        ));
1155                    }
1156                    Err(e) => {
1157                        let tab = self.active_tab_mut();
1158                        tab.file_history_path = None;
1159                        tab.error_message = Some(format!("File history failed: {e}"));
1160                    }
1161                }
1162                Task::none()
1163            }
1164
1165            Message::CloseFileHistory => {
1166                let tab = self.active_tab_mut();
1167                tab.file_history_path = None;
1168                tab.file_history_commits.clear();
1169                tab.file_history_scroll = 0.0;
1170                Task::none()
1171            }
1172
1173            Message::FileHistoryScrolled(y) => {
1174                self.active_tab_mut().file_history_scroll = *y;
1175                Task::none()
1176            }
1177
1178            Message::SelectFileHistoryCommit(oid) => {
1179                let oid = oid.clone();
1180                let repo_path = self.active_tab().repo_path.clone();
1181                {
1182                    let tab = self.active_tab_mut();
1183                    tab.file_history_path = None;
1184                    tab.file_history_commits.clear();
1185                    tab.selected_commit_oid = Some(oid.clone());
1186                    tab.commit_files.clear();
1187                    tab.selected_diff = None;
1188                    tab.show_commit_detail = true;
1189                }
1190                if let Some(path) = repo_path {
1191                    crate::features::commits::commands::load_commit_file_list(path, oid)
1192                } else {
1193                    Task::none()
1194                }
1195            }
1196
1197            // ── Blame overlay ─────────────────────────────────────────────────────
1198            Message::ViewFileBlame(path) => {
1199                let path = path.clone();
1200                if let Some(repo_path) = self.active_tab().repo_path.clone() {
1201                    let tab = self.active_tab_mut();
1202                    tab.file_history_path = None; // close history if open
1203                    tab.blame_path = Some(path.clone());
1204                    tab.blame_lines.clear();
1205                    tab.blame_scroll = 0.0;
1206                    tab.context_menu = None;
1207                    tab.status_message = Some(format!(
1208                        "Loading blame for {}…",
1209                        path.rsplit('/').next().unwrap_or(&path)
1210                    ));
1211                    crate::features::repo::commands::blame_file_async(repo_path, path)
1212                } else {
1213                    Task::none()
1214                }
1215            }
1216
1217            Message::FileBlameLoaded(result) => {
1218                match result {
1219                    Ok((path, lines)) => {
1220                        let tab = self.active_tab_mut();
1221                        tab.blame_path = Some(path.clone());
1222                        tab.blame_lines = lines.clone();
1223                        tab.status_message = Some(format!(
1224                            "Blame: {} ({} lines)",
1225                            path.rsplit('/').next().unwrap_or(path),
1226                            lines.len()
1227                        ));
1228                    }
1229                    Err(e) => {
1230                        let tab = self.active_tab_mut();
1231                        tab.blame_path = None;
1232                        tab.error_message = Some(format!("Blame failed: {e}"));
1233                    }
1234                }
1235                Task::none()
1236            }
1237
1238            Message::CloseFileBlame => {
1239                let tab = self.active_tab_mut();
1240                tab.blame_path = None;
1241                tab.blame_lines.clear();
1242                tab.blame_scroll = 0.0;
1243                Task::none()
1244            }
1245
1246            Message::BlameScrolled(y) => {
1247                self.active_tab_mut().blame_scroll = *y;
1248                Task::none()
1249            }
1250
1251            // ── File deletion ─────────────────────────────────────────────────────
1252            Message::DeleteFile(path) => {
1253                let tab = self.active_tab_mut();
1254                tab.context_menu = None;
1255                tab.pending_delete_file = Some(path.clone());
1256                tab.status_message = Some(format!(
1257                    "Delete '{}' — press Confirm to delete permanently",
1258                    path.rsplit('/').next().unwrap_or(path)
1259                ));
1260                Task::none()
1261            }
1262
1263            Message::ConfirmDeleteFile => {
1264                let path = self.active_tab().pending_delete_file.clone();
1265                let repo_path = self.active_tab().repo_path.clone();
1266                if let (Some(file_path), Some(repo_path)) = (path, repo_path) {
1267                    let tab = self.active_tab_mut();
1268                    tab.pending_delete_file = None;
1269                    tab.is_loading = true;
1270                    tab.status_message = Some(format!(
1271                        "Deleting '{}'…",
1272                        file_path.rsplit('/').next().unwrap_or(&file_path)
1273                    ));
1274                    crate::features::repo::commands::delete_file_async(repo_path, file_path)
1275                } else {
1276                    Task::none()
1277                }
1278            }
1279
1280            Message::CancelDeleteFile => {
1281                let tab = self.active_tab_mut();
1282                tab.pending_delete_file = None;
1283                tab.status_message = None;
1284                Task::none()
1285            }
1286
1287            Message::Noop => Task::none(),
1288
1289            Message::CherryPickCommits(oids) => {
1290                let oids = oids.clone();
1291                self.active_tab_mut().context_menu = None;
1292                with_repo!(
1293                    self,
1294                    loading,
1295                    format!("Cherry-picking {} commit(s)…", oids.len()),
1296                    |path| crate::features::repo::commands::cherry_pick_commits_async(path, oids)
1297                )
1298            }
1299
1300            Message::RevertCommits(oids) => {
1301                let oids = oids.clone();
1302                self.active_tab_mut().context_menu = None;
1303                with_repo!(
1304                    self,
1305                    loading,
1306                    format!("Reverting {} commit(s)…", oids.len()),
1307                    |path| crate::features::repo::commands::revert_commits_async(path, oids)
1308                )
1309            }
1310        }
1311    }
1312}