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