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                // Both the commits and diff features care about SelectCommit.
89                // We delegate to the commits handler which also loads the diff.
90                crate::features::commits::update::update(self, message)
91            }
92
93            Message::CommitMessageChanged(_)
94            | Message::CreateCommit
95            | Message::CommitCreated(_) => crate::features::commits::update::update(self, message),
96
97            Message::CommitLogScrolled(abs_y, rel_y) => {
98                // relative_y is 0.0 at the top and 1.0 at the very bottom of
99                // the scrollable content.  Using it (rather than absolute_y)
100                // avoids needing to know the viewport height.
101                const COMMITS_PAGE_SIZE: usize = 200;
102                // Trigger a load when the user is in the last 15 % of the
103                // scrollable area — roughly 2–3 screen-heights from the end.
104                const LOAD_TRIGGER_RELATIVE: f32 = 0.85;
105
106                self.active_tab_mut().commit_scroll_offset = *abs_y;
107
108                let tab = self.active_tab();
109                if *rel_y >= LOAD_TRIGGER_RELATIVE
110                    && tab.has_more_commits
111                    && !tab.is_loading_more_commits
112                {
113                    if let Some(path) = tab.repo_path.clone() {
114                        let current = tab.commits.len();
115                        self.active_tab_mut().is_loading_more_commits = true;
116                        return crate::features::repo::commands::load_more_commits(
117                            path,
118                            current,
119                            COMMITS_PAGE_SIZE,
120                        );
121                    }
122                }
123                Task::none()
124            }
125
126            Message::DiffViewScrolled(abs_y) => {
127                self.active_tab_mut().diff_scroll_offset = *abs_y;
128                Task::none()
129            }
130
131            // ── Staging ───────────────────────────────────────────────────
132            Message::StageFile(_)
133            | Message::UnstageFile(_)
134            | Message::StageAll
135            | Message::UnstageAll
136            | Message::DiscardFile(_)
137            | Message::ConfirmDiscard(_)
138            | Message::CancelDiscard
139            | Message::StagingUpdated(_)
140            | Message::ToggleSelectUnstaged(_)
141            | Message::ToggleSelectStaged(_)
142            | Message::StageSelected
143            | Message::UnstageSelected
144            | Message::DiscardSelected
145            | Message::DiscardStagedFile(_) => {
146                crate::features::staging::update::update(self, message)
147            }
148
149            // ── Stash ─────────────────────────────────────────────────────
150            Message::StashSave
151            | Message::StashPop(_)
152            | Message::StashDrop(_)
153            | Message::StashUpdated(_)
154            | Message::StashMessageChanged(_)
155            | Message::StashApply(_)
156            | Message::ViewStashDiff(_)
157            | Message::StashDiffLoaded(_) => crate::features::stash::update::update(self, message),
158
159            // ── Remotes ───────────────────────────────────────────────────
160            Message::Fetch | Message::FetchCompleted(_) => {
161                crate::features::remotes::update::update(self, message)
162            }
163
164            // ── UI / misc ─────────────────────────────────────────────────
165            Message::SelectDiff(_) | Message::SelectDiffByIndex(_) => {
166                crate::features::diff::update::update(self, message)
167            }
168
169            Message::DismissError => {
170                self.active_tab_mut().error_message = None;
171                Task::none()
172            }
173
174            Message::ZoomIn => {
175                self.ui_scale = (self.ui_scale + 0.1).min(2.0);
176                crate::features::repo::commands::save_layout_async(self.current_layout())
177            }
178
179            Message::ZoomOut => {
180                self.ui_scale = (self.ui_scale - 0.1).max(0.5);
181                crate::features::repo::commands::save_layout_async(self.current_layout())
182            }
183
184            Message::ZoomReset => {
185                self.ui_scale = 1.0;
186                crate::features::repo::commands::save_layout_async(self.current_layout())
187            }
188
189            Message::ToggleSidebar => {
190                self.sidebar_expanded = !self.sidebar_expanded;
191                crate::features::repo::commands::save_layout_async(self.current_layout())
192            }
193
194            // ── Pane resize ───────────────────────────────────────────────
195            Message::PaneDragStart(target, _x) => {
196                self.dragging = Some(*target);
197                // Position is 0.0 because `on_press` doesn't provide coords.
198                // We set drag_initialized to false so the first `PaneDragMove`
199                // captures the real position instead of computing a bogus delta.
200                self.drag_initialized = false;
201                Task::none()
202            }
203
204            Message::PaneDragStartH(target, _y) => {
205                self.dragging_h = Some(*target);
206                self.drag_initialized_h = false;
207                Task::none()
208            }
209
210            Message::PaneDragMove(x, y) => {
211                use crate::state::{DragTarget, DragTargetH};
212
213                // Always record cursor position so context menus open at the pointer.
214                self.cursor_pos = iced::Point::new(*x, *y);
215
216                if let Some(target) = self.dragging {
217                    if !self.drag_initialized {
218                        // First move after press — just record the position.
219                        self.drag_start_x = *x;
220                        self.drag_initialized = true;
221                    } else {
222                        let dx = *x - self.drag_start_x;
223                        self.drag_start_x = *x;
224
225                        match target {
226                            DragTarget::SidebarRight => {
227                                self.sidebar_width = (self.sidebar_width + dx).clamp(120.0, 500.0);
228                            }
229                            DragTarget::CommitLogRight => {
230                                self.commit_log_width =
231                                    (self.commit_log_width + dx).clamp(200.0, 1200.0);
232                            }
233                            DragTarget::DiffFileListRight => {
234                                self.diff_file_list_width =
235                                    (self.diff_file_list_width + dx).clamp(100.0, 400.0);
236                            }
237                        }
238                    }
239                }
240
241                if let Some(target_h) = self.dragging_h {
242                    if !self.drag_initialized_h {
243                        self.drag_start_y = *y;
244                        self.drag_initialized_h = true;
245                    } else {
246                        let dy = *y - self.drag_start_y;
247                        self.drag_start_y = *y;
248
249                        match target_h {
250                            DragTargetH::StagingTop => {
251                                // Dragging up → larger staging area (subtract dy).
252                                self.staging_height =
253                                    (self.staging_height - dy).clamp(100.0, 600.0);
254                            }
255                        }
256                    }
257                }
258
259                Task::none()
260            }
261
262            Message::PaneDragEnd => {
263                self.dragging = None;
264                self.dragging_h = None;
265                self.drag_initialized = false;
266                self.drag_initialized_h = false;
267                crate::features::repo::commands::save_layout_async(self.current_layout())
268            }
269
270            // ── Context menu lifecycle ────────────────────────────────────────────────
271            Message::OpenBranchContextMenu(name, local_index, is_current) => {
272                let pos = (self.cursor_pos.x, self.cursor_pos.y);
273                let tab = self.active_tab_mut();
274                tab.context_menu_pos = pos;
275                tab.context_menu = Some(crate::state::ContextMenu::Branch {
276                    name: name.clone(),
277                    is_current: *is_current,
278                    local_index: *local_index,
279                });
280                Task::none()
281            }
282
283            Message::OpenRemoteBranchContextMenu(name) => {
284                let pos = (self.cursor_pos.x, self.cursor_pos.y);
285                let tab = self.active_tab_mut();
286                tab.context_menu_pos = pos;
287                tab.context_menu =
288                    Some(crate::state::ContextMenu::RemoteBranch { name: name.clone() });
289                Task::none()
290            }
291
292            Message::OpenCommitFileContextMenu(oid, file_path) => {
293                let pos = (self.cursor_pos.x, self.cursor_pos.y);
294                let tab = self.active_tab_mut();
295                tab.context_menu_pos = pos;
296                tab.context_menu = Some(crate::state::ContextMenu::CommitFile {
297                    oid: oid.clone(),
298                    file_path: file_path.clone(),
299                });
300                Task::none()
301            }
302
303            Message::OpenStashContextMenu(index) => {
304                let index = *index;
305                let pos = (self.cursor_pos.x, self.cursor_pos.y);
306                let tab = self.active_tab_mut();
307                tab.context_menu_pos = pos;
308                tab.context_menu = Some(crate::state::ContextMenu::Stash { index });
309                Task::none()
310            }
311
312            Message::OpenUnstagedFileContextMenu(path) => {
313                let pos = (self.cursor_pos.x, self.cursor_pos.y);
314                let tab = self.active_tab_mut();
315                tab.context_menu_pos = pos;
316                tab.context_menu =
317                    Some(crate::state::ContextMenu::UnstagedFile { path: path.clone() });
318                Task::none()
319            }
320
321            Message::OpenStagedFileContextMenu(path) => {
322                let pos = (self.cursor_pos.x, self.cursor_pos.y);
323                let tab = self.active_tab_mut();
324                tab.context_menu_pos = pos;
325                tab.context_menu =
326                    Some(crate::state::ContextMenu::StagedFile { path: path.clone() });
327                Task::none()
328            }
329
330            Message::OpenCommitContextMenu(idx) => {
331                let oid = self.active_tab().commits.get(*idx).map(|c| c.oid.clone());
332                let pos = (self.cursor_pos.x, self.cursor_pos.y);
333                if let Some(oid) = oid {
334                    let tab = self.active_tab_mut();
335                    tab.context_menu_pos = pos;
336                    tab.context_menu = Some(crate::state::ContextMenu::Commit { index: *idx, oid });
337                }
338                Task::none()
339            }
340
341            Message::OpenSearchResultContextMenu(idx) => {
342                if let Some(commit) = self.search_results.get(*idx) {
343                    let oid = commit.oid.clone();
344                    let pos = (self.cursor_pos.x, self.cursor_pos.y);
345                    let tab = self.active_tab_mut();
346                    tab.context_menu_pos = pos;
347                    tab.context_menu = Some(crate::state::ContextMenu::Commit { index: *idx, oid });
348                }
349                Task::none()
350            }
351
352            Message::CloseContextMenu => {
353                self.active_tab_mut().context_menu = None;
354                Task::none()
355            }
356
357            // ── Inline branch rename ──────────────────────────────────────────────────
358            Message::BeginRenameBranch(name) => {
359                let tab = self.active_tab_mut();
360                tab.context_menu = None;
361                tab.rename_branch_input = name.clone();
362                tab.rename_branch_target = Some(name.clone());
363                Task::none()
364            }
365
366            Message::RenameBranchInputChanged(s) => {
367                self.active_tab_mut().rename_branch_input = s.clone();
368                Task::none()
369            }
370
371            Message::CancelRename => {
372                let tab = self.active_tab_mut();
373                tab.rename_branch_target = None;
374                tab.rename_branch_input.clear();
375                Task::none()
376            }
377
378            Message::ConfirmRenameBranch => {
379                let (original, new_name, path) = {
380                    let tab = self.active_tab();
381                    (
382                        tab.rename_branch_target.clone(),
383                        tab.rename_branch_input.trim().to_string(),
384                        tab.repo_path.clone(),
385                    )
386                };
387                if let (Some(orig), false) = (&original, new_name.is_empty()) {
388                    if *orig != new_name {
389                        if let Some(path) = path {
390                            let orig = orig.clone();
391                            {
392                                let tab = self.active_tab_mut();
393                                tab.rename_branch_target = None;
394                                tab.rename_branch_input.clear();
395                                tab.is_loading = true;
396                                tab.status_message =
397                                    Some(format!("Renaming '{orig}' → '{new_name}'…"));
398                            }
399                            return crate::features::repo::commands::rename_branch_async(
400                                path, orig, new_name,
401                            );
402                        }
403                    }
404                }
405                self.active_tab_mut().rename_branch_target = None;
406                Task::none()
407            }
408
409            // ── Branch context menu actions ───────────────────────────────────────────
410            Message::PushBranch(name) => {
411                let name = name.clone();
412                let remote = self
413                    .active_tab()
414                    .remotes
415                    .first()
416                    .map(|r| r.name.clone())
417                    .unwrap_or_else(|| "origin".to_string());
418                self.active_tab_mut().context_menu = None;
419                with_repo!(
420                    self,
421                    loading,
422                    format!("Pushing '{name}' to {remote}…"),
423                    |path| crate::features::repo::commands::push_branch_async(path, name, remote)
424                )
425            }
426
427            Message::PullBranch(_name) => {
428                let remote = self
429                    .active_tab()
430                    .remotes
431                    .first()
432                    .map(|r| r.name.clone())
433                    .unwrap_or_else(|| "origin".to_string());
434                self.active_tab_mut().context_menu = None;
435                with_repo!(
436                    self,
437                    loading,
438                    format!("Pulling from {remote} (rebase)…"),
439                    |path| crate::features::repo::commands::pull_rebase_async(path, remote)
440                )
441            }
442
443            Message::RebaseOnto(target) => {
444                let target = target.clone();
445                self.active_tab_mut().context_menu = None;
446                with_repo!(
447                    self,
448                    loading,
449                    format!("Rebasing onto '{target}'…"),
450                    |path| crate::features::repo::commands::rebase_onto_async(path, target)
451                )
452            }
453
454            Message::MergeBranch(name) => {
455                let name = name.clone();
456                self.active_tab_mut().context_menu = None;
457                with_repo!(
458                    self,
459                    loading,
460                    format!("Merging '{name}' into current branch…"),
461                    |path| crate::features::repo::commands::merge_branch_async(path, name)
462                )
463            }
464
465            Message::CheckoutRemoteBranch(name) => {
466                let name = name.clone();
467                self.active_tab_mut().context_menu = None;
468                with_repo!(self, loading, format!("Checking out '{name}'…"), |path| {
469                    crate::features::repo::commands::checkout_remote_branch_async(path, name)
470                })
471            }
472
473            Message::DeleteRemoteBranch(name) => {
474                let name = name.clone();
475                self.active_tab_mut().context_menu = None;
476                with_repo!(
477                    self,
478                    loading,
479                    format!("Deleting remote branch '{name}'…"),
480                    |path| crate::features::repo::commands::delete_remote_branch_async(path, name)
481                )
482            }
483
484            Message::BeginCreateTag(oid, annotated) => {
485                let tab = self.active_tab_mut();
486                tab.context_menu = None;
487                tab.create_tag_target_oid = Some(oid.clone());
488                tab.create_tag_annotated = *annotated;
489                tab.create_tag_name.clear();
490                tab.create_tag_message.clear();
491                Task::none()
492            }
493
494            Message::TagNameChanged(s) => {
495                self.active_tab_mut().create_tag_name = s.clone();
496                Task::none()
497            }
498
499            Message::TagMessageChanged(s) => {
500                self.active_tab_mut().create_tag_message = s.clone();
501                Task::none()
502            }
503
504            Message::ConfirmCreateTag => {
505                let (oid, name, message, annotated, path) = {
506                    let tab = self.active_tab();
507                    (
508                        tab.create_tag_target_oid.clone(),
509                        tab.create_tag_name.trim().to_string(),
510                        tab.create_tag_message.trim().to_string(),
511                        tab.create_tag_annotated,
512                        tab.repo_path.clone(),
513                    )
514                };
515                if let (Some(oid), false) = (&oid, name.is_empty()) {
516                    if let Some(path) = path {
517                        let oid = oid.clone();
518                        {
519                            let tab = self.active_tab_mut();
520                            tab.create_tag_target_oid = None;
521                            tab.create_tag_name.clear();
522                            tab.create_tag_message.clear();
523                            tab.is_loading = true;
524                            tab.status_message = Some(format!("Creating tag '{name}'…"));
525                        }
526                        return if annotated {
527                            crate::features::repo::commands::create_annotated_tag_async(
528                                path, name, message, oid,
529                            )
530                        } else {
531                            crate::features::repo::commands::create_tag_async(path, name, oid)
532                        };
533                    }
534                }
535                Task::none()
536            }
537
538            Message::CancelCreateTag => {
539                let tab = self.active_tab_mut();
540                tab.create_tag_target_oid = None;
541                tab.create_tag_name.clear();
542                tab.create_tag_message.clear();
543                Task::none()
544            }
545
546            // ── Commit context menu actions ───────────────────────────────────────────
547            Message::CheckoutCommitDetached(oid) => {
548                let oid = oid.clone();
549                let short = gitkraft_core::utils::short_oid_str(&oid).to_string();
550                self.active_tab_mut().context_menu = None;
551                with_repo!(self, loading, format!("Checking out {short}…"), |path| {
552                    crate::features::repo::commands::checkout_commit_async(path, oid)
553                })
554            }
555
556            Message::RebaseOntoCommit(oid) => {
557                let oid = oid.clone();
558                let short = gitkraft_core::utils::short_oid_str(&oid).to_string();
559                self.active_tab_mut().context_menu = None;
560                with_repo!(self, loading, format!("Rebasing onto {short}…"), |path| {
561                    crate::features::repo::commands::rebase_onto_async(path, oid)
562                })
563            }
564
565            Message::RevertCommit(oid) => {
566                let oid = oid.clone();
567                let short = gitkraft_core::utils::short_oid_str(&oid).to_string();
568                self.active_tab_mut().context_menu = None;
569                with_repo!(self, loading, format!("Reverting {short}…"), |path| {
570                    crate::features::repo::commands::revert_commit_async(path, oid)
571                })
572            }
573
574            Message::ResetSoft(ref oid)
575            | Message::ResetMixed(ref oid)
576            | Message::ResetHard(ref oid) => {
577                let mode = match &message {
578                    Message::ResetSoft(_) => "soft",
579                    Message::ResetMixed(_) => "mixed",
580                    Message::ResetHard(_) => "hard",
581                    _ => unreachable!(),
582                };
583                let oid = oid.clone();
584                let short = gitkraft_core::utils::short_oid_str(&oid).to_string();
585                self.active_tab_mut().context_menu = None;
586                with_repo!(
587                    self,
588                    loading,
589                    format!("Resetting ({mode}) to {short}…"),
590                    |path| crate::features::repo::commands::reset_to_commit_async(
591                        path,
592                        oid,
593                        mode.to_string()
594                    )
595                )
596            }
597
598            // ── Shared ───────────────────────────────────────────────────────────────
599            Message::CopyText(text) => {
600                self.active_tab_mut().context_menu = None;
601                iced::clipboard::write(text.clone())
602            }
603
604            // ── Persistence / misc ────────────────────────────────────────
605            Message::ThemeChanged(index) => {
606                self.current_theme_index = *index;
607                // Persist the selected theme name on a background thread.
608                let name = gitkraft_core::THEME_NAMES
609                    .get(*index)
610                    .copied()
611                    .unwrap_or("Default");
612                crate::features::repo::commands::save_theme_async(name.to_string())
613            }
614
615            Message::ThemeSaved(_result) => {
616                // Fire-and-forget — errors are silently ignored.
617                Task::none()
618            }
619
620            Message::EditorChanged(editor) => {
621                self.editor = editor.clone();
622                self.active_tab_mut().status_message =
623                    Some(format!("Editor set to {}", self.editor));
624                // Persist the editor choice
625                let name = self.editor.display_name().to_string();
626                crate::features::repo::commands::save_editor_async(name)
627            }
628
629            Message::EditorSaved(_result) => {
630                // Fire-and-forget — errors are silently ignored.
631                Task::none()
632            }
633
634            Message::LayoutSaved(_result) => {
635                // Fire-and-forget — errors are silently ignored.
636                Task::none()
637            }
638
639            Message::SessionSaved(_) => {
640                // Fire-and-forget — errors are silently ignored.
641                Task::none()
642            }
643
644            Message::LayoutLoaded(result) => {
645                if let Ok(Some(layout)) = result {
646                    if let Some(w) = layout.sidebar_width {
647                        self.sidebar_width = w;
648                    }
649                    if let Some(w) = layout.commit_log_width {
650                        self.commit_log_width = w;
651                    }
652                    if let Some(h) = layout.staging_height {
653                        self.staging_height = h;
654                    }
655                    if let Some(w) = layout.diff_file_list_width {
656                        self.diff_file_list_width = w;
657                    }
658                    if let Some(expanded) = layout.sidebar_expanded {
659                        self.sidebar_expanded = expanded;
660                    }
661                    if let Some(scale) = layout.ui_scale {
662                        self.ui_scale = scale.clamp(0.5, 2.0);
663                    }
664                }
665                Task::none()
666            }
667
668            // ── Search ────────────────────────────────────────────────────
669            Message::ToggleSearch => {
670                self.search_visible = !self.search_visible;
671                if !self.search_visible {
672                    self.search_query.clear();
673                    self.search_results.clear();
674                    self.search_selected = None;
675                    self.search_diff_files.clear();
676                    self.search_diff_selected.clear();
677                    self.search_diff_content.clear();
678                    self.search_diff_oid = None;
679                    Task::none()
680                } else {
681                    iced::widget::operation::focus_next()
682                }
683            }
684
685            Message::SearchQueryChanged(query) => {
686                let query = query.clone();
687                self.search_query = query.clone();
688                if query.trim().len() >= 2 {
689                    if let Some(path) = self.active_tab().repo_path.clone() {
690                        return crate::features::commits::commands::search_commits(path, query);
691                    }
692                } else {
693                    self.search_results.clear();
694                    self.search_selected = None;
695                }
696                Task::none()
697            }
698
699            Message::SearchResultsLoaded(result) => {
700                match result {
701                    Ok(results) => {
702                        self.search_results = results.clone();
703                        self.search_selected = if self.search_results.is_empty() {
704                            None
705                        } else {
706                            Some(0)
707                        };
708                    }
709                    Err(e) => {
710                        self.search_results.clear();
711                        self.active_tab_mut().error_message = Some(format!("Search failed: {e}"));
712                    }
713                }
714                Task::none()
715            }
716
717            Message::SelectSearchResult(index) => {
718                let index = *index;
719                if index < self.search_results.len() {
720                    self.search_selected = Some(index);
721                }
722                Task::none()
723            }
724
725            Message::ConfirmSearchResult => {
726                if let Some(idx) = self.search_selected {
727                    if let Some(commit) = self.search_results.get(idx).cloned() {
728                        let oid = commit.oid.clone();
729                        // Keep search open — load the file list for commit vs working tree
730                        self.search_diff_oid = Some(oid.clone());
731                        self.search_diff_files.clear();
732                        self.search_diff_selected.clear();
733                        self.search_diff_content.clear();
734
735                        if let Some(path) = self.active_tab().repo_path.clone() {
736                            return crate::features::commits::commands::search_diff_file_list(
737                                path, oid,
738                            );
739                        }
740                    }
741                }
742                Task::none()
743            }
744
745            Message::SearchDiffFilesLoaded(result) => {
746                match result {
747                    Ok(files) => {
748                        self.search_diff_files = files.clone();
749                        self.search_diff_selected.clear();
750                        self.search_diff_content.clear();
751                    }
752                    Err(e) => {
753                        self.active_tab_mut().error_message =
754                            Some(format!("Failed to load diff files: {e}"));
755                    }
756                }
757                Task::none()
758            }
759
760            Message::ToggleSearchDiffFile(index) => {
761                let index = *index;
762                if self.search_diff_selected.contains(&index) {
763                    self.search_diff_selected.remove(&index);
764                } else {
765                    self.search_diff_selected.insert(index);
766                }
767                Task::none()
768            }
769
770            Message::ToggleSearchDiffSelectAll => {
771                if self.search_diff_selected.len() == self.search_diff_files.len() {
772                    self.search_diff_selected.clear();
773                } else {
774                    self.search_diff_selected = (0..self.search_diff_files.len()).collect();
775                }
776                Task::none()
777            }
778
779            Message::ViewSearchDiffFile(index) => {
780                let index = *index;
781                if let Some(file) = self.search_diff_files.get(index) {
782                    let file_path = file.display_path().to_string();
783                    if let (Some(oid), Some(repo_path)) = (
784                        self.search_diff_oid.clone(),
785                        self.active_tab().repo_path.clone(),
786                    ) {
787                        return crate::features::commits::commands::search_diff_file(
788                            repo_path, oid, file_path,
789                        );
790                    }
791                }
792                Task::none()
793            }
794
795            Message::SearchFileDiffLoaded(result) => {
796                match result {
797                    Ok(diff) => {
798                        self.search_diff_content = vec![diff.clone()];
799                    }
800                    Err(e) => {
801                        self.active_tab_mut().error_message =
802                            Some(format!("Failed to load file diff: {e}"));
803                    }
804                }
805                Task::none()
806            }
807
808            Message::DiffSelectedFiles => {
809                if self.search_diff_selected.is_empty() {
810                    return Task::none();
811                }
812                let file_paths: Vec<String> = self
813                    .search_diff_selected
814                    .iter()
815                    .filter_map(|&i| self.search_diff_files.get(i))
816                    .map(|f| f.display_path().to_string())
817                    .collect();
818                if let (Some(oid), Some(repo_path)) = (
819                    self.search_diff_oid.clone(),
820                    self.active_tab().repo_path.clone(),
821                ) {
822                    return crate::features::commits::commands::search_diff_multi_files(
823                        repo_path, oid, file_paths,
824                    );
825                }
826                Task::none()
827            }
828
829            Message::SearchMultiDiffLoaded(result) => {
830                match result {
831                    Ok(diffs) => {
832                        self.search_diff_content = diffs.clone();
833                    }
834                    Err(e) => {
835                        self.active_tab_mut().error_message =
836                            Some(format!("Failed to load diffs: {e}"));
837                    }
838                }
839                Task::none()
840            }
841
842            Message::SearchDiffBack => {
843                self.search_diff_content.clear();
844                Task::none()
845            }
846
847            Message::FileSystemChanged => {
848                if self.has_repo() && !self.active_tab().is_loading {
849                    if let Some(path) = self.active_tab().repo_path.clone() {
850                        return crate::features::repo::commands::refresh_staging_only(path);
851                    }
852                }
853                Task::none()
854            }
855
856            Message::OpenInEditor(path) => {
857                self.active_tab_mut().context_menu = None;
858                if matches!(self.editor, gitkraft_core::Editor::None) {
859                    self.active_tab_mut().status_message = Some(
860                        "No editor configured — select one from the editor dropdown in the toolbar"
861                            .into(),
862                    );
863                    return Task::none();
864                }
865                if let Some(repo_path) = self.active_tab().repo_path.as_ref() {
866                    let full_path = repo_path.join(path);
867                    match self.editor.open_file(&full_path) {
868                        Ok(()) => {
869                            self.active_tab_mut().status_message =
870                                Some(format!("Opened in {}", self.editor));
871                        }
872                        Err(e) => {
873                            self.active_tab_mut().error_message =
874                                Some(format!("Failed to open editor: {e}"));
875                        }
876                    }
877                }
878                Task::none()
879            }
880
881            Message::OpenInDefaultProgram(path) => {
882                self.active_tab_mut().context_menu = None;
883                if let Some(repo_path) = self.active_tab().repo_path.as_ref() {
884                    let full_path = repo_path.join(path);
885                    if let Err(e) = gitkraft_core::open_file_default(&full_path) {
886                        self.active_tab_mut().error_message =
887                            Some(format!("Failed to open file: {e}"));
888                    }
889                }
890                Task::none()
891            }
892
893            Message::ShowInFolder(path) => {
894                self.active_tab_mut().context_menu = None;
895                if let Some(repo_path) = self.active_tab().repo_path.as_ref() {
896                    let full_path = repo_path.join(path);
897                    if let Err(e) = gitkraft_core::show_in_folder(&full_path) {
898                        self.active_tab_mut().error_message =
899                            Some(format!("Failed to show in folder: {e}"));
900                    }
901                }
902                Task::none()
903            }
904
905            Message::Noop => Task::none(),
906        }
907    }
908}