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::ToggleSidebar => {
201                self.sidebar_expanded = !self.sidebar_expanded;
202                crate::features::repo::commands::save_layout_async(self.current_layout())
203            }
204
205            // ── Pane resize ───────────────────────────────────────────────
206            Message::PaneDragStart(target, _x) => {
207                self.dragging = Some(*target);
208                // Position is 0.0 because `on_press` doesn't provide coords.
209                // We set drag_initialized to false so the first `PaneDragMove`
210                // captures the real position instead of computing a bogus delta.
211                self.drag_initialized = false;
212                Task::none()
213            }
214
215            Message::PaneDragStartH(target, _y) => {
216                self.dragging_h = Some(*target);
217                self.drag_initialized_h = false;
218                Task::none()
219            }
220
221            Message::PaneDragMove(x, y) => {
222                use crate::state::{DragTarget, DragTargetH};
223
224                // Always record cursor position so context menus open at the pointer.
225                self.cursor_pos = iced::Point::new(*x, *y);
226
227                if let Some(target) = self.dragging {
228                    if !self.drag_initialized {
229                        // First move after press — just record the position.
230                        self.drag_start_x = *x;
231                        self.drag_initialized = true;
232                    } else {
233                        let dx = *x - self.drag_start_x;
234                        self.drag_start_x = *x;
235
236                        match target {
237                            DragTarget::SidebarRight => {
238                                self.sidebar_width = (self.sidebar_width + dx).clamp(120.0, 500.0);
239                            }
240                            DragTarget::CommitLogRight => {
241                                self.commit_log_width =
242                                    (self.commit_log_width + dx).clamp(200.0, 1200.0);
243                            }
244                            DragTarget::DiffFileListRight => {
245                                self.diff_file_list_width =
246                                    (self.diff_file_list_width + dx).clamp(100.0, 400.0);
247                            }
248                        }
249                    }
250                }
251
252                if let Some(target_h) = self.dragging_h {
253                    if !self.drag_initialized_h {
254                        self.drag_start_y = *y;
255                        self.drag_initialized_h = true;
256                    } else {
257                        let dy = *y - self.drag_start_y;
258                        self.drag_start_y = *y;
259
260                        match target_h {
261                            DragTargetH::StagingTop => {
262                                // Dragging up → larger staging area (subtract dy).
263                                self.staging_height =
264                                    (self.staging_height - dy).clamp(100.0, 600.0);
265                            }
266                        }
267                    }
268                }
269
270                Task::none()
271            }
272
273            Message::PaneDragEnd => {
274                self.dragging = None;
275                self.dragging_h = None;
276                self.drag_initialized = false;
277                self.drag_initialized_h = false;
278                crate::features::repo::commands::save_layout_async(self.current_layout())
279            }
280
281            // ── Context menu lifecycle ────────────────────────────────────────────────
282            Message::OpenBranchContextMenu(name, local_index, is_current) => {
283                let pos = (self.cursor_pos.x, self.cursor_pos.y);
284                let tab = self.active_tab_mut();
285                tab.context_menu_pos = pos;
286                tab.context_menu = Some(crate::state::ContextMenu::Branch {
287                    name: name.clone(),
288                    is_current: *is_current,
289                    local_index: *local_index,
290                });
291                Task::none()
292            }
293
294            Message::OpenRemoteBranchContextMenu(name) => {
295                let pos = (self.cursor_pos.x, self.cursor_pos.y);
296                let tab = self.active_tab_mut();
297                tab.context_menu_pos = pos;
298                tab.context_menu =
299                    Some(crate::state::ContextMenu::RemoteBranch { name: name.clone() });
300                Task::none()
301            }
302
303            Message::OpenCommitFileContextMenu(oid, file_path) => {
304                let pos = (self.cursor_pos.x, self.cursor_pos.y);
305                let tab = self.active_tab_mut();
306                tab.context_menu_pos = pos;
307                tab.context_menu = Some(crate::state::ContextMenu::CommitFile {
308                    oid: oid.clone(),
309                    file_path: file_path.clone(),
310                });
311                Task::none()
312            }
313
314            Message::OpenStashContextMenu(index) => {
315                let index = *index;
316                let pos = (self.cursor_pos.x, self.cursor_pos.y);
317                let tab = self.active_tab_mut();
318                tab.context_menu_pos = pos;
319                tab.context_menu = Some(crate::state::ContextMenu::Stash { index });
320                Task::none()
321            }
322
323            Message::OpenUnstagedFileContextMenu(path) => {
324                let pos = (self.cursor_pos.x, self.cursor_pos.y);
325                let tab = self.active_tab_mut();
326                tab.context_menu_pos = pos;
327                tab.context_menu =
328                    Some(crate::state::ContextMenu::UnstagedFile { path: path.clone() });
329                Task::none()
330            }
331
332            Message::OpenStagedFileContextMenu(path) => {
333                let pos = (self.cursor_pos.x, self.cursor_pos.y);
334                let tab = self.active_tab_mut();
335                tab.context_menu_pos = pos;
336                tab.context_menu =
337                    Some(crate::state::ContextMenu::StagedFile { path: path.clone() });
338                Task::none()
339            }
340
341            Message::OpenCommitContextMenu(idx) => {
342                let oid = self.active_tab().commits.get(*idx).map(|c| c.oid.clone());
343                let pos = (self.cursor_pos.x, self.cursor_pos.y);
344                if let Some(oid) = oid {
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::OpenSearchResultContextMenu(idx) => {
353                if let Some(commit) = self.search_results.get(*idx) {
354                    let oid = commit.oid.clone();
355                    let pos = (self.cursor_pos.x, self.cursor_pos.y);
356                    let tab = self.active_tab_mut();
357                    tab.context_menu_pos = pos;
358                    tab.context_menu = Some(crate::state::ContextMenu::Commit { index: *idx, oid });
359                }
360                Task::none()
361            }
362
363            Message::CloseContextMenu => {
364                self.active_tab_mut().context_menu = None;
365                Task::none()
366            }
367
368            // ── Inline branch rename ──────────────────────────────────────────────────
369            Message::BeginRenameBranch(name) => {
370                let tab = self.active_tab_mut();
371                tab.context_menu = None;
372                tab.rename_branch_input = name.clone();
373                tab.rename_branch_target = Some(name.clone());
374                Task::none()
375            }
376
377            Message::RenameBranchInputChanged(s) => {
378                self.active_tab_mut().rename_branch_input = s.clone();
379                Task::none()
380            }
381
382            Message::CancelRename => {
383                let tab = self.active_tab_mut();
384                tab.rename_branch_target = None;
385                tab.rename_branch_input.clear();
386                Task::none()
387            }
388
389            Message::ConfirmRenameBranch => {
390                let (original, new_name, path) = {
391                    let tab = self.active_tab();
392                    (
393                        tab.rename_branch_target.clone(),
394                        tab.rename_branch_input.trim().to_string(),
395                        tab.repo_path.clone(),
396                    )
397                };
398                if let (Some(orig), false) = (&original, new_name.is_empty()) {
399                    if *orig != new_name {
400                        if let Some(path) = path {
401                            let orig = orig.clone();
402                            {
403                                let tab = self.active_tab_mut();
404                                tab.rename_branch_target = None;
405                                tab.rename_branch_input.clear();
406                                tab.is_loading = true;
407                                tab.status_message =
408                                    Some(format!("Renaming '{orig}' → '{new_name}'…"));
409                            }
410                            return crate::features::repo::commands::rename_branch_async(
411                                path, orig, new_name,
412                            );
413                        }
414                    }
415                }
416                self.active_tab_mut().rename_branch_target = None;
417                Task::none()
418            }
419
420            // ── Branch context menu actions ───────────────────────────────────────────
421            Message::PushBranch(name) => {
422                let name = name.clone();
423                let remote = self
424                    .active_tab()
425                    .remotes
426                    .first()
427                    .map(|r| r.name.clone())
428                    .unwrap_or_else(|| "origin".to_string());
429                self.active_tab_mut().context_menu = None;
430                with_repo!(
431                    self,
432                    loading,
433                    format!("Pushing '{name}' to {remote}…"),
434                    |path| crate::features::repo::commands::push_branch_async(path, name, remote)
435                )
436            }
437
438            Message::PullBranch(_name) => {
439                let remote = self
440                    .active_tab()
441                    .remotes
442                    .first()
443                    .map(|r| r.name.clone())
444                    .unwrap_or_else(|| "origin".to_string());
445                self.active_tab_mut().context_menu = None;
446                with_repo!(
447                    self,
448                    loading,
449                    format!("Pulling from {remote} (rebase)…"),
450                    |path| crate::features::repo::commands::pull_rebase_async(path, remote)
451                )
452            }
453
454            Message::RebaseOnto(target) => {
455                let target = target.clone();
456                self.active_tab_mut().context_menu = None;
457                with_repo!(
458                    self,
459                    loading,
460                    format!("Rebasing onto '{target}'…"),
461                    |path| crate::features::repo::commands::rebase_onto_async(path, target)
462                )
463            }
464
465            Message::MergeBranch(name) => {
466                let name = name.clone();
467                self.active_tab_mut().context_menu = None;
468                with_repo!(
469                    self,
470                    loading,
471                    format!("Merging '{name}' into current branch…"),
472                    |path| crate::features::repo::commands::merge_branch_async(path, name)
473                )
474            }
475
476            Message::CheckoutRemoteBranch(name) => {
477                let name = name.clone();
478                self.active_tab_mut().context_menu = None;
479                with_repo!(self, loading, format!("Checking out '{name}'…"), |path| {
480                    crate::features::repo::commands::checkout_remote_branch_async(path, name)
481                })
482            }
483
484            Message::DeleteRemoteBranch(name) => {
485                let name = name.clone();
486                self.active_tab_mut().context_menu = None;
487                with_repo!(
488                    self,
489                    loading,
490                    format!("Deleting remote branch '{name}'…"),
491                    |path| crate::features::repo::commands::delete_remote_branch_async(path, name)
492                )
493            }
494
495            Message::BeginCreateTag(oid, annotated) => {
496                let tab = self.active_tab_mut();
497                tab.context_menu = None;
498                tab.create_tag_target_oid = Some(oid.clone());
499                tab.create_tag_annotated = *annotated;
500                tab.create_tag_name.clear();
501                tab.create_tag_message.clear();
502                Task::none()
503            }
504
505            Message::TagNameChanged(s) => {
506                self.active_tab_mut().create_tag_name = s.clone();
507                Task::none()
508            }
509
510            Message::TagMessageChanged(s) => {
511                self.active_tab_mut().create_tag_message = s.clone();
512                Task::none()
513            }
514
515            Message::ConfirmCreateTag => {
516                let (oid, name, message, annotated, path) = {
517                    let tab = self.active_tab();
518                    (
519                        tab.create_tag_target_oid.clone(),
520                        tab.create_tag_name.trim().to_string(),
521                        tab.create_tag_message.trim().to_string(),
522                        tab.create_tag_annotated,
523                        tab.repo_path.clone(),
524                    )
525                };
526                if let (Some(oid), false) = (&oid, name.is_empty()) {
527                    if let Some(path) = path {
528                        let oid = oid.clone();
529                        {
530                            let tab = self.active_tab_mut();
531                            tab.create_tag_target_oid = None;
532                            tab.create_tag_name.clear();
533                            tab.create_tag_message.clear();
534                            tab.is_loading = true;
535                            tab.status_message = Some(format!("Creating tag '{name}'…"));
536                        }
537                        return if annotated {
538                            crate::features::repo::commands::create_annotated_tag_async(
539                                path, name, message, oid,
540                            )
541                        } else {
542                            crate::features::repo::commands::create_tag_async(path, name, oid)
543                        };
544                    }
545                }
546                Task::none()
547            }
548
549            Message::CancelCreateTag => {
550                let tab = self.active_tab_mut();
551                tab.create_tag_target_oid = None;
552                tab.create_tag_name.clear();
553                tab.create_tag_message.clear();
554                Task::none()
555            }
556
557            Message::CherryPickCommit(oid) => {
558                let oid = oid.clone();
559                let short = gitkraft_core::utils::short_oid_str(&oid).to_string();
560                self.active_tab_mut().context_menu = None;
561                with_repo!(self, loading, format!("Cherry-picking {short}…"), |path| {
562                    crate::features::repo::commands::cherry_pick_async(path, oid)
563                })
564            }
565
566            Message::BeginCreateBranchAtCommit(oid) => {
567                let tab = self.active_tab_mut();
568                tab.context_menu = None;
569                tab.create_branch_at_oid = Some(oid.clone());
570                tab.new_branch_name.clear();
571                Task::none()
572            }
573
574            Message::ConfirmCreateBranchAtCommit => {
575                let (oid, name, path) = {
576                    let tab = self.active_tab();
577                    (
578                        tab.create_branch_at_oid.clone(),
579                        tab.new_branch_name.trim().to_string(),
580                        tab.repo_path.clone(),
581                    )
582                };
583                if let (Some(oid), false) = (&oid, name.is_empty()) {
584                    if let Some(path) = path {
585                        let oid = oid.clone();
586                        {
587                            let tab = self.active_tab_mut();
588                            tab.create_branch_at_oid = None;
589                            tab.new_branch_name.clear();
590                            tab.is_loading = true;
591                            tab.status_message = Some(format!("Creating branch '{name}'…"));
592                        }
593                        return crate::features::repo::commands::create_branch_at_commit_async(
594                            path, name, oid,
595                        );
596                    }
597                }
598                Task::none()
599            }
600
601            Message::CancelCreateBranchAtCommit => {
602                let tab = self.active_tab_mut();
603                tab.create_branch_at_oid = None;
604                tab.new_branch_name.clear();
605                Task::none()
606            }
607
608            // ── Commit context menu actions ───────────────────────────────────────
609            Message::ExecuteCommitAction(oid, action) => {
610                let oid = oid.clone();
611                let action = action.clone();
612                let label = action.label();
613                self.active_tab_mut().context_menu = None;
614                with_repo!(self, loading, format!("{label}…"), |path| {
615                    crate::features::repo::commands::execute_commit_action_async(path, oid, action)
616                })
617            }
618
619            Message::CheckoutCommitDetached(oid) => {
620                let oid = oid.clone();
621                let short = gitkraft_core::utils::short_oid_str(&oid).to_string();
622                self.active_tab_mut().context_menu = None;
623                with_repo!(self, loading, format!("Checking out {short}…"), |path| {
624                    crate::features::repo::commands::checkout_commit_async(path, oid)
625                })
626            }
627
628            Message::RebaseOntoCommit(oid) => {
629                let oid = oid.clone();
630                let short = gitkraft_core::utils::short_oid_str(&oid).to_string();
631                self.active_tab_mut().context_menu = None;
632                with_repo!(self, loading, format!("Rebasing onto {short}…"), |path| {
633                    crate::features::repo::commands::rebase_onto_async(path, oid)
634                })
635            }
636
637            Message::RevertCommit(oid) => {
638                let oid = oid.clone();
639                let short = gitkraft_core::utils::short_oid_str(&oid).to_string();
640                self.active_tab_mut().context_menu = None;
641                with_repo!(self, loading, format!("Reverting {short}…"), |path| {
642                    crate::features::repo::commands::revert_commit_async(path, oid)
643                })
644            }
645
646            Message::ResetSoft(ref oid)
647            | Message::ResetMixed(ref oid)
648            | Message::ResetHard(ref oid) => {
649                let mode = match &message {
650                    Message::ResetSoft(_) => "soft",
651                    Message::ResetMixed(_) => "mixed",
652                    Message::ResetHard(_) => "hard",
653                    _ => unreachable!(),
654                };
655                let oid = oid.clone();
656                let short = gitkraft_core::utils::short_oid_str(&oid).to_string();
657                self.active_tab_mut().context_menu = None;
658                with_repo!(
659                    self,
660                    loading,
661                    format!("Resetting ({mode}) to {short}…"),
662                    |path| crate::features::repo::commands::reset_to_commit_async(
663                        path,
664                        oid,
665                        mode.to_string()
666                    )
667                )
668            }
669
670            // ── Shared ───────────────────────────────────────────────────────────────
671            Message::CopyText(text) => {
672                self.active_tab_mut().context_menu = None;
673                iced::clipboard::write(text.clone())
674            }
675
676            // ── Persistence / misc ────────────────────────────────────────
677            Message::ThemeChanged(index) => {
678                self.current_theme_index = *index;
679                // Persist the selected theme name on a background thread.
680                let name = gitkraft_core::THEME_NAMES
681                    .get(*index)
682                    .copied()
683                    .unwrap_or("Default");
684                crate::features::repo::commands::save_theme_async(name.to_string())
685            }
686
687            Message::ThemeSaved(_result) => {
688                // Fire-and-forget — errors are silently ignored.
689                Task::none()
690            }
691
692            Message::EditorChanged(editor) => {
693                self.editor = editor.clone();
694                self.active_tab_mut().status_message =
695                    Some(format!("Editor set to {}", self.editor));
696                // Persist the editor choice
697                let name = self.editor.display_name().to_string();
698                crate::features::repo::commands::save_editor_async(name)
699            }
700
701            Message::EditorSaved(_result) => {
702                // Fire-and-forget — errors are silently ignored.
703                Task::none()
704            }
705
706            Message::LayoutSaved(_result) => {
707                // Fire-and-forget — errors are silently ignored.
708                Task::none()
709            }
710
711            Message::SessionSaved(_) => {
712                // Fire-and-forget — errors are silently ignored.
713                Task::none()
714            }
715
716            Message::LayoutLoaded(result) => {
717                if let Ok(Some(layout)) = result {
718                    if let Some(w) = layout.sidebar_width {
719                        self.sidebar_width = w;
720                    }
721                    if let Some(w) = layout.commit_log_width {
722                        self.commit_log_width = w;
723                    }
724                    if let Some(h) = layout.staging_height {
725                        self.staging_height = h;
726                    }
727                    if let Some(w) = layout.diff_file_list_width {
728                        self.diff_file_list_width = w;
729                    }
730                    if let Some(expanded) = layout.sidebar_expanded {
731                        self.sidebar_expanded = expanded;
732                    }
733                    if let Some(scale) = layout.ui_scale {
734                        self.ui_scale = scale.clamp(0.5, 2.0);
735                    }
736                }
737                Task::none()
738            }
739
740            // ── Search ────────────────────────────────────────────────────
741            Message::ToggleSearch => {
742                self.search_visible = !self.search_visible;
743                if !self.search_visible {
744                    self.search_query.clear();
745                    self.search_results.clear();
746                    self.search_selected = None;
747                    self.search_diff_files.clear();
748                    self.search_diff_selected.clear();
749                    self.search_diff_content.clear();
750                    self.search_diff_oid = None;
751                    Task::none()
752                } else {
753                    iced::widget::operation::focus_next()
754                }
755            }
756
757            Message::SearchQueryChanged(query) => {
758                let query = query.clone();
759                self.search_query = query.clone();
760                if query.trim().len() >= 2 {
761                    if let Some(path) = self.active_tab().repo_path.clone() {
762                        return crate::features::commits::commands::search_commits(path, query);
763                    }
764                } else {
765                    self.search_results.clear();
766                    self.search_selected = None;
767                }
768                Task::none()
769            }
770
771            Message::SearchResultsLoaded(result) => {
772                match result {
773                    Ok(results) => {
774                        self.search_results = results.clone();
775                        self.search_selected = if self.search_results.is_empty() {
776                            None
777                        } else {
778                            Some(0)
779                        };
780                    }
781                    Err(e) => {
782                        self.search_results.clear();
783                        self.active_tab_mut().error_message = Some(format!("Search failed: {e}"));
784                    }
785                }
786                Task::none()
787            }
788
789            Message::SelectSearchResult(index) => {
790                let index = *index;
791                if index < self.search_results.len() {
792                    self.search_selected = Some(index);
793                }
794                Task::none()
795            }
796
797            Message::ConfirmSearchResult => {
798                if let Some(idx) = self.search_selected {
799                    if let Some(commit) = self.search_results.get(idx).cloned() {
800                        let oid = commit.oid.clone();
801                        // Keep search open — load the file list for commit vs working tree
802                        self.search_diff_oid = Some(oid.clone());
803                        self.search_diff_files.clear();
804                        self.search_diff_selected.clear();
805                        self.search_diff_content.clear();
806
807                        if let Some(path) = self.active_tab().repo_path.clone() {
808                            return crate::features::commits::commands::search_diff_file_list(
809                                path, oid,
810                            );
811                        }
812                    }
813                }
814                Task::none()
815            }
816
817            Message::SearchDiffFilesLoaded(result) => {
818                match result {
819                    Ok(files) => {
820                        self.search_diff_files = files.clone();
821                        self.search_diff_selected.clear();
822                        self.search_diff_content.clear();
823                    }
824                    Err(e) => {
825                        self.active_tab_mut().error_message =
826                            Some(format!("Failed to load diff files: {e}"));
827                    }
828                }
829                Task::none()
830            }
831
832            Message::ToggleSearchDiffFile(index) => {
833                let index = *index;
834                if self.search_diff_selected.contains(&index) {
835                    self.search_diff_selected.remove(&index);
836                } else {
837                    self.search_diff_selected.insert(index);
838                }
839                Task::none()
840            }
841
842            Message::ToggleSearchDiffSelectAll => {
843                if self.search_diff_selected.len() == self.search_diff_files.len() {
844                    self.search_diff_selected.clear();
845                } else {
846                    self.search_diff_selected = (0..self.search_diff_files.len()).collect();
847                }
848                Task::none()
849            }
850
851            Message::ViewSearchDiffFile(index) => {
852                let index = *index;
853                if let Some(file) = self.search_diff_files.get(index) {
854                    let file_path = file.display_path().to_string();
855                    if let (Some(oid), Some(repo_path)) = (
856                        self.search_diff_oid.clone(),
857                        self.active_tab().repo_path.clone(),
858                    ) {
859                        return crate::features::commits::commands::search_diff_file(
860                            repo_path, oid, file_path,
861                        );
862                    }
863                }
864                Task::none()
865            }
866
867            Message::SearchFileDiffLoaded(result) => {
868                match result {
869                    Ok(diff) => {
870                        self.search_diff_content = vec![diff.clone()];
871                    }
872                    Err(e) => {
873                        self.active_tab_mut().error_message =
874                            Some(format!("Failed to load file diff: {e}"));
875                    }
876                }
877                Task::none()
878            }
879
880            Message::DiffSelectedFiles => {
881                if self.search_diff_selected.is_empty() {
882                    return Task::none();
883                }
884                let file_paths: Vec<String> = self
885                    .search_diff_selected
886                    .iter()
887                    .filter_map(|&i| self.search_diff_files.get(i))
888                    .map(|f| f.display_path().to_string())
889                    .collect();
890                if let (Some(oid), Some(repo_path)) = (
891                    self.search_diff_oid.clone(),
892                    self.active_tab().repo_path.clone(),
893                ) {
894                    return crate::features::commits::commands::search_diff_multi_files(
895                        repo_path, oid, file_paths,
896                    );
897                }
898                Task::none()
899            }
900
901            Message::SearchMultiDiffLoaded(result) => {
902                match result {
903                    Ok(diffs) => {
904                        self.search_diff_content = diffs.clone();
905                    }
906                    Err(e) => {
907                        self.active_tab_mut().error_message =
908                            Some(format!("Failed to load diffs: {e}"));
909                    }
910                }
911                Task::none()
912            }
913
914            Message::SearchDiffBack => {
915                self.search_diff_content.clear();
916                Task::none()
917            }
918
919            Message::FileSystemChanged => {
920                if self.has_repo() && !self.active_tab().is_loading {
921                    if let Some(path) = self.active_tab().repo_path.clone() {
922                        return crate::features::repo::commands::refresh_staging_only(path);
923                    }
924                }
925                Task::none()
926            }
927
928            Message::OpenInEditor(path) => {
929                self.active_tab_mut().context_menu = None;
930                if matches!(self.editor, gitkraft_core::Editor::None) {
931                    self.active_tab_mut().status_message = Some(
932                        "No editor configured — select one from the editor dropdown in the toolbar"
933                            .into(),
934                    );
935                    return Task::none();
936                }
937                if let Some(repo_path) = self.active_tab().repo_path.as_ref() {
938                    let full_path = repo_path.join(path);
939                    match self.editor.open_file(&full_path) {
940                        Ok(()) => {
941                            self.active_tab_mut().status_message =
942                                Some(format!("Opened in {}", self.editor));
943                        }
944                        Err(e) => {
945                            self.active_tab_mut().error_message =
946                                Some(format!("Failed to open editor: {e}"));
947                        }
948                    }
949                }
950                Task::none()
951            }
952
953            Message::OpenInDefaultProgram(path) => {
954                self.active_tab_mut().context_menu = None;
955                if let Some(repo_path) = self.active_tab().repo_path.as_ref() {
956                    let full_path = repo_path.join(path);
957                    if let Err(e) = gitkraft_core::open_file_default(&full_path) {
958                        self.active_tab_mut().error_message =
959                            Some(format!("Failed to open file: {e}"));
960                    }
961                }
962                Task::none()
963            }
964
965            Message::ShowInFolder(path) => {
966                self.active_tab_mut().context_menu = None;
967                if let Some(repo_path) = self.active_tab().repo_path.as_ref() {
968                    let full_path = repo_path.join(path);
969                    if let Err(e) = gitkraft_core::show_in_folder(&full_path) {
970                        self.active_tab_mut().error_message =
971                            Some(format!("Failed to show in folder: {e}"));
972                    }
973                }
974                Task::none()
975            }
976
977            Message::Noop => Task::none(),
978
979            Message::CherryPickCommits(oids) => {
980                let oids = oids.clone();
981                self.active_tab_mut().context_menu = None;
982                with_repo!(
983                    self,
984                    loading,
985                    format!("Cherry-picking {} commit(s)…", oids.len()),
986                    |path| crate::features::repo::commands::cherry_pick_commits_async(path, oids)
987                )
988            }
989
990            Message::RevertCommits(oids) => {
991                let oids = oids.clone();
992                self.active_tab_mut().context_menu = None;
993                with_repo!(
994                    self,
995                    loading,
996                    format!("Reverting {} commit(s)…", oids.len()),
997                    |path| crate::features::repo::commands::revert_commits_async(path, oids)
998                )
999            }
1000        }
1001    }
1002}