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                }
676                Task::none()
677            }
678
679            Message::SearchQueryChanged(query) => {
680                let query = query.clone();
681                self.search_query = query.clone();
682                if query.trim().len() >= 2 {
683                    if let Some(path) = self.active_tab().repo_path.clone() {
684                        return crate::features::commits::commands::search_commits(path, query);
685                    }
686                } else {
687                    self.search_results.clear();
688                    self.search_selected = None;
689                }
690                Task::none()
691            }
692
693            Message::SearchResultsLoaded(result) => {
694                match result {
695                    Ok(results) => {
696                        self.search_results = results.clone();
697                        self.search_selected = if self.search_results.is_empty() {
698                            None
699                        } else {
700                            Some(0)
701                        };
702                    }
703                    Err(e) => {
704                        self.search_results.clear();
705                        self.active_tab_mut().error_message = Some(format!("Search failed: {e}"));
706                    }
707                }
708                Task::none()
709            }
710
711            Message::SelectSearchResult(index) => {
712                let index = *index;
713                if index < self.search_results.len() {
714                    self.search_selected = Some(index);
715                }
716                Task::none()
717            }
718
719            Message::ConfirmSearchResult => {
720                if let Some(idx) = self.search_selected {
721                    if let Some(commit) = self.search_results.get(idx).cloned() {
722                        let oid = commit.oid.clone();
723                        let short_oid = commit.short_oid.clone();
724                        // Find this commit's index in the loaded commit list and select it
725                        let commit_idx =
726                            self.active_tab().commits.iter().position(|c| c.oid == oid);
727                        if let Some(ci) = commit_idx {
728                            let tab = self.active_tab_mut();
729                            tab.selected_commit = Some(ci);
730                            tab.show_commit_detail = true;
731                        }
732                        // Close search and load the diff
733                        self.search_query.clear();
734                        self.search_results.clear();
735                        self.search_selected = None;
736
737                        if let Some(path) = self.active_tab().repo_path.clone() {
738                            let tab = self.active_tab_mut();
739                            tab.status_message = Some(format!("Loading diff for {short_oid}…"));
740                            tab.selected_commit_oid = Some(oid.clone());
741                            return crate::features::commits::commands::load_commit_file_list(
742                                path, oid,
743                            );
744                        }
745                    }
746                }
747                Task::none()
748            }
749
750            Message::FileSystemChanged => {
751                if self.has_repo() && !self.active_tab().is_loading {
752                    if let Some(path) = self.active_tab().repo_path.clone() {
753                        return crate::features::repo::commands::refresh_staging_only(path);
754                    }
755                }
756                Task::none()
757            }
758
759            Message::OpenInEditor(path) => {
760                self.active_tab_mut().context_menu = None;
761                if matches!(self.editor, gitkraft_core::Editor::None) {
762                    self.active_tab_mut().status_message = Some(
763                        "No editor configured — select one from the editor dropdown in the toolbar"
764                            .into(),
765                    );
766                    return Task::none();
767                }
768                if let Some(repo_path) = self.active_tab().repo_path.as_ref() {
769                    let full_path = repo_path.join(path);
770                    match self.editor.open_file(&full_path) {
771                        Ok(()) => {
772                            self.active_tab_mut().status_message =
773                                Some(format!("Opened in {}", self.editor));
774                        }
775                        Err(e) => {
776                            self.active_tab_mut().error_message =
777                                Some(format!("Failed to open editor: {e}"));
778                        }
779                    }
780                }
781                Task::none()
782            }
783
784            Message::OpenInDefaultProgram(path) => {
785                self.active_tab_mut().context_menu = None;
786                if let Some(repo_path) = self.active_tab().repo_path.as_ref() {
787                    let full_path = repo_path.join(path);
788                    if let Err(e) = gitkraft_core::open_file_default(&full_path) {
789                        self.active_tab_mut().error_message =
790                            Some(format!("Failed to open file: {e}"));
791                    }
792                }
793                Task::none()
794            }
795
796            Message::ShowInFolder(path) => {
797                self.active_tab_mut().context_menu = None;
798                if let Some(repo_path) = self.active_tab().repo_path.as_ref() {
799                    let full_path = repo_path.join(path);
800                    if let Err(e) = gitkraft_core::show_in_folder(&full_path) {
801                        self.active_tab_mut().error_message =
802                            Some(format!("Failed to show in folder: {e}"));
803                    }
804                }
805                Task::none()
806            }
807
808            Message::Noop => Task::none(),
809        }
810    }
811}