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                // Both the commits and diff features care about SelectCommit.
87                // We delegate to the commits handler which also loads the diff.
88                crate::features::commits::update::update(self, message)
89            }
90
91            Message::CommitMessageChanged(_)
92            | Message::CreateCommit
93            | Message::CommitCreated(_) => crate::features::commits::update::update(self, message),
94
95            Message::CommitLogScrolled(abs_y, rel_y) => {
96                // relative_y is 0.0 at the top and 1.0 at the very bottom of
97                // the scrollable content.  Using it (rather than absolute_y)
98                // avoids needing to know the viewport height.
99                const COMMITS_PAGE_SIZE: usize = 200;
100                // Trigger a load when the user is in the last 15 % of the
101                // scrollable area — roughly 2–3 screen-heights from the end.
102                const LOAD_TRIGGER_RELATIVE: f32 = 0.85;
103
104                self.active_tab_mut().commit_scroll_offset = *abs_y;
105
106                let tab = self.active_tab();
107                if *rel_y >= LOAD_TRIGGER_RELATIVE
108                    && tab.has_more_commits
109                    && !tab.is_loading_more_commits
110                {
111                    if let Some(path) = tab.repo_path.clone() {
112                        let current = tab.commits.len();
113                        self.active_tab_mut().is_loading_more_commits = true;
114                        return crate::features::repo::commands::load_more_commits(
115                            path,
116                            current,
117                            COMMITS_PAGE_SIZE,
118                        );
119                    }
120                }
121                Task::none()
122            }
123
124            Message::DiffViewScrolled(abs_y) => {
125                self.active_tab_mut().diff_scroll_offset = *abs_y;
126                Task::none()
127            }
128
129            // ── Staging ───────────────────────────────────────────────────
130            Message::StageFile(_)
131            | Message::UnstageFile(_)
132            | Message::StageAll
133            | Message::UnstageAll
134            | Message::DiscardFile(_)
135            | Message::ConfirmDiscard(_)
136            | Message::CancelDiscard
137            | Message::StagingUpdated(_) => crate::features::staging::update::update(self, message),
138
139            // ── Stash ─────────────────────────────────────────────────────
140            Message::StashSave
141            | Message::StashPop(_)
142            | Message::StashDrop(_)
143            | Message::StashUpdated(_)
144            | Message::StashMessageChanged(_) => {
145                crate::features::stash::update::update(self, message)
146            }
147
148            // ── Remotes ───────────────────────────────────────────────────
149            Message::Fetch | Message::FetchCompleted(_) => {
150                crate::features::remotes::update::update(self, message)
151            }
152
153            // ── UI / misc ─────────────────────────────────────────────────
154            Message::SelectDiff(_) | Message::SelectDiffByIndex(_) => {
155                crate::features::diff::update::update(self, message)
156            }
157
158            Message::DismissError => {
159                self.active_tab_mut().error_message = None;
160                Task::none()
161            }
162
163            Message::ZoomIn => {
164                self.ui_scale = (self.ui_scale + 0.1).min(2.0);
165                crate::features::repo::commands::save_layout_async(self.current_layout())
166            }
167
168            Message::ZoomOut => {
169                self.ui_scale = (self.ui_scale - 0.1).max(0.5);
170                crate::features::repo::commands::save_layout_async(self.current_layout())
171            }
172
173            Message::ZoomReset => {
174                self.ui_scale = 1.0;
175                crate::features::repo::commands::save_layout_async(self.current_layout())
176            }
177
178            Message::ToggleSidebar => {
179                self.sidebar_expanded = !self.sidebar_expanded;
180                crate::features::repo::commands::save_layout_async(self.current_layout())
181            }
182
183            // ── Pane resize ───────────────────────────────────────────────
184            Message::PaneDragStart(target, _x) => {
185                self.dragging = Some(*target);
186                // Position is 0.0 because `on_press` doesn't provide coords.
187                // We set drag_initialized to false so the first `PaneDragMove`
188                // captures the real position instead of computing a bogus delta.
189                self.drag_initialized = false;
190                Task::none()
191            }
192
193            Message::PaneDragStartH(target, _y) => {
194                self.dragging_h = Some(*target);
195                self.drag_initialized_h = false;
196                Task::none()
197            }
198
199            Message::PaneDragMove(x, y) => {
200                use crate::state::{DragTarget, DragTargetH};
201
202                // Always record cursor position so context menus open at the pointer.
203                self.cursor_pos = iced::Point::new(*x, *y);
204
205                if let Some(target) = self.dragging {
206                    if !self.drag_initialized {
207                        // First move after press — just record the position.
208                        self.drag_start_x = *x;
209                        self.drag_initialized = true;
210                    } else {
211                        let dx = *x - self.drag_start_x;
212                        self.drag_start_x = *x;
213
214                        match target {
215                            DragTarget::SidebarRight => {
216                                self.sidebar_width = (self.sidebar_width + dx).clamp(120.0, 500.0);
217                            }
218                            DragTarget::CommitLogRight => {
219                                self.commit_log_width =
220                                    (self.commit_log_width + dx).clamp(200.0, 1200.0);
221                            }
222                            DragTarget::DiffFileListRight => {
223                                self.diff_file_list_width =
224                                    (self.diff_file_list_width + dx).clamp(100.0, 400.0);
225                            }
226                        }
227                    }
228                }
229
230                if let Some(target_h) = self.dragging_h {
231                    if !self.drag_initialized_h {
232                        self.drag_start_y = *y;
233                        self.drag_initialized_h = true;
234                    } else {
235                        let dy = *y - self.drag_start_y;
236                        self.drag_start_y = *y;
237
238                        match target_h {
239                            DragTargetH::StagingTop => {
240                                // Dragging up → larger staging area (subtract dy).
241                                self.staging_height =
242                                    (self.staging_height - dy).clamp(100.0, 600.0);
243                            }
244                        }
245                    }
246                }
247
248                Task::none()
249            }
250
251            Message::PaneDragEnd => {
252                self.dragging = None;
253                self.dragging_h = None;
254                self.drag_initialized = false;
255                self.drag_initialized_h = false;
256                crate::features::repo::commands::save_layout_async(self.current_layout())
257            }
258
259            // ── Context menu lifecycle ────────────────────────────────────────────────
260            Message::OpenBranchContextMenu(name, local_index, is_current) => {
261                let pos = (self.cursor_pos.x, self.cursor_pos.y);
262                let tab = self.active_tab_mut();
263                tab.context_menu_pos = pos;
264                tab.context_menu = Some(crate::state::ContextMenu::Branch {
265                    name: name.clone(),
266                    is_current: *is_current,
267                    local_index: *local_index,
268                });
269                Task::none()
270            }
271
272            Message::OpenRemoteBranchContextMenu(name) => {
273                let pos = (self.cursor_pos.x, self.cursor_pos.y);
274                let tab = self.active_tab_mut();
275                tab.context_menu_pos = pos;
276                tab.context_menu =
277                    Some(crate::state::ContextMenu::RemoteBranch { name: name.clone() });
278                Task::none()
279            }
280
281            Message::OpenCommitContextMenu(idx) => {
282                let oid = self.active_tab().commits.get(*idx).map(|c| c.oid.clone());
283                let pos = (self.cursor_pos.x, self.cursor_pos.y);
284                if let Some(oid) = oid {
285                    let tab = self.active_tab_mut();
286                    tab.context_menu_pos = pos;
287                    tab.context_menu = Some(crate::state::ContextMenu::Commit { index: *idx, oid });
288                }
289                Task::none()
290            }
291
292            Message::CloseContextMenu => {
293                self.active_tab_mut().context_menu = None;
294                Task::none()
295            }
296
297            // ── Inline branch rename ──────────────────────────────────────────────────
298            Message::BeginRenameBranch(name) => {
299                let tab = self.active_tab_mut();
300                tab.context_menu = None;
301                tab.rename_branch_input = name.clone();
302                tab.rename_branch_target = Some(name.clone());
303                Task::none()
304            }
305
306            Message::RenameBranchInputChanged(s) => {
307                self.active_tab_mut().rename_branch_input = s.clone();
308                Task::none()
309            }
310
311            Message::CancelRename => {
312                let tab = self.active_tab_mut();
313                tab.rename_branch_target = None;
314                tab.rename_branch_input.clear();
315                Task::none()
316            }
317
318            Message::ConfirmRenameBranch => {
319                let (original, new_name, path) = {
320                    let tab = self.active_tab();
321                    (
322                        tab.rename_branch_target.clone(),
323                        tab.rename_branch_input.trim().to_string(),
324                        tab.repo_path.clone(),
325                    )
326                };
327                if let (Some(orig), false) = (&original, new_name.is_empty()) {
328                    if *orig != new_name {
329                        if let Some(path) = path {
330                            let orig = orig.clone();
331                            {
332                                let tab = self.active_tab_mut();
333                                tab.rename_branch_target = None;
334                                tab.rename_branch_input.clear();
335                                tab.is_loading = true;
336                                tab.status_message =
337                                    Some(format!("Renaming '{orig}' → '{new_name}'…"));
338                            }
339                            return crate::features::repo::commands::rename_branch_async(
340                                path, orig, new_name,
341                            );
342                        }
343                    }
344                }
345                self.active_tab_mut().rename_branch_target = None;
346                Task::none()
347            }
348
349            // ── Branch context menu actions ───────────────────────────────────────────
350            Message::PushBranch(name) => {
351                let name = name.clone();
352                let remote = self
353                    .active_tab()
354                    .remotes
355                    .first()
356                    .map(|r| r.name.clone())
357                    .unwrap_or_else(|| "origin".to_string());
358                self.active_tab_mut().context_menu = None;
359                with_repo!(
360                    self,
361                    loading,
362                    format!("Pushing '{name}' to {remote}…"),
363                    |path| crate::features::repo::commands::push_branch_async(path, name, remote)
364                )
365            }
366
367            Message::PullBranch(_name) => {
368                let remote = self
369                    .active_tab()
370                    .remotes
371                    .first()
372                    .map(|r| r.name.clone())
373                    .unwrap_or_else(|| "origin".to_string());
374                self.active_tab_mut().context_menu = None;
375                with_repo!(
376                    self,
377                    loading,
378                    format!("Pulling from {remote} (rebase)…"),
379                    |path| crate::features::repo::commands::pull_rebase_async(path, remote)
380                )
381            }
382
383            Message::RebaseOnto(target) => {
384                let target = target.clone();
385                self.active_tab_mut().context_menu = None;
386                with_repo!(
387                    self,
388                    loading,
389                    format!("Rebasing onto '{target}'…"),
390                    |path| crate::features::repo::commands::rebase_onto_async(path, target)
391                )
392            }
393
394            Message::MergeBranch(name) => {
395                let name = name.clone();
396                self.active_tab_mut().context_menu = None;
397                with_repo!(
398                    self,
399                    loading,
400                    format!("Merging '{name}' into current branch…"),
401                    |path| crate::features::repo::commands::merge_branch_async(path, name)
402                )
403            }
404
405            Message::CheckoutRemoteBranch(name) => {
406                let name = name.clone();
407                self.active_tab_mut().context_menu = None;
408                with_repo!(self, loading, format!("Checking out '{name}'…"), |path| {
409                    crate::features::repo::commands::checkout_remote_branch_async(path, name)
410                })
411            }
412
413            Message::DeleteRemoteBranch(name) => {
414                let name = name.clone();
415                self.active_tab_mut().context_menu = None;
416                with_repo!(
417                    self,
418                    loading,
419                    format!("Deleting remote branch '{name}'…"),
420                    |path| crate::features::repo::commands::delete_remote_branch_async(path, name)
421                )
422            }
423
424            Message::BeginCreateTag(oid, annotated) => {
425                let tab = self.active_tab_mut();
426                tab.context_menu = None;
427                tab.create_tag_target_oid = Some(oid.clone());
428                tab.create_tag_annotated = *annotated;
429                tab.create_tag_name.clear();
430                tab.create_tag_message.clear();
431                Task::none()
432            }
433
434            Message::TagNameChanged(s) => {
435                self.active_tab_mut().create_tag_name = s.clone();
436                Task::none()
437            }
438
439            Message::TagMessageChanged(s) => {
440                self.active_tab_mut().create_tag_message = s.clone();
441                Task::none()
442            }
443
444            Message::ConfirmCreateTag => {
445                let (oid, name, message, annotated, path) = {
446                    let tab = self.active_tab();
447                    (
448                        tab.create_tag_target_oid.clone(),
449                        tab.create_tag_name.trim().to_string(),
450                        tab.create_tag_message.trim().to_string(),
451                        tab.create_tag_annotated,
452                        tab.repo_path.clone(),
453                    )
454                };
455                if let (Some(oid), false) = (&oid, name.is_empty()) {
456                    if let Some(path) = path {
457                        let oid = oid.clone();
458                        {
459                            let tab = self.active_tab_mut();
460                            tab.create_tag_target_oid = None;
461                            tab.create_tag_name.clear();
462                            tab.create_tag_message.clear();
463                            tab.is_loading = true;
464                            tab.status_message = Some(format!("Creating tag '{name}'…"));
465                        }
466                        return if annotated {
467                            crate::features::repo::commands::create_annotated_tag_async(
468                                path, name, message, oid,
469                            )
470                        } else {
471                            crate::features::repo::commands::create_tag_async(path, name, oid)
472                        };
473                    }
474                }
475                Task::none()
476            }
477
478            Message::CancelCreateTag => {
479                let tab = self.active_tab_mut();
480                tab.create_tag_target_oid = None;
481                tab.create_tag_name.clear();
482                tab.create_tag_message.clear();
483                Task::none()
484            }
485
486            // ── Commit context menu actions ───────────────────────────────────────────
487            Message::CheckoutCommitDetached(oid) => {
488                let oid = oid.clone();
489                let short = gitkraft_core::utils::short_oid_str(&oid).to_string();
490                self.active_tab_mut().context_menu = None;
491                with_repo!(self, loading, format!("Checking out {short}…"), |path| {
492                    crate::features::repo::commands::checkout_commit_async(path, oid)
493                })
494            }
495
496            Message::RebaseOntoCommit(oid) => {
497                let oid = oid.clone();
498                let short = gitkraft_core::utils::short_oid_str(&oid).to_string();
499                self.active_tab_mut().context_menu = None;
500                with_repo!(self, loading, format!("Rebasing onto {short}…"), |path| {
501                    crate::features::repo::commands::rebase_onto_async(path, oid)
502                })
503            }
504
505            Message::RevertCommit(oid) => {
506                let oid = oid.clone();
507                let short = gitkraft_core::utils::short_oid_str(&oid).to_string();
508                self.active_tab_mut().context_menu = None;
509                with_repo!(self, loading, format!("Reverting {short}…"), |path| {
510                    crate::features::repo::commands::revert_commit_async(path, oid)
511                })
512            }
513
514            Message::ResetSoft(ref oid)
515            | Message::ResetMixed(ref oid)
516            | Message::ResetHard(ref oid) => {
517                let mode = match &message {
518                    Message::ResetSoft(_) => "soft",
519                    Message::ResetMixed(_) => "mixed",
520                    Message::ResetHard(_) => "hard",
521                    _ => unreachable!(),
522                };
523                let oid = oid.clone();
524                let short = gitkraft_core::utils::short_oid_str(&oid).to_string();
525                self.active_tab_mut().context_menu = None;
526                with_repo!(
527                    self,
528                    loading,
529                    format!("Resetting ({mode}) to {short}…"),
530                    |path| crate::features::repo::commands::reset_to_commit_async(
531                        path,
532                        oid,
533                        mode.to_string()
534                    )
535                )
536            }
537
538            // ── Shared ───────────────────────────────────────────────────────────────
539            Message::CopyText(text) => iced::clipboard::write(text.clone()),
540
541            // ── Persistence / misc ────────────────────────────────────────
542            Message::ThemeChanged(index) => {
543                self.current_theme_index = *index;
544                // Persist the selected theme name on a background thread.
545                let name = gitkraft_core::THEME_NAMES
546                    .get(*index)
547                    .copied()
548                    .unwrap_or("Default");
549                crate::features::repo::commands::save_theme_async(name.to_string())
550            }
551
552            Message::ThemeSaved(_result) => {
553                // Fire-and-forget — errors are silently ignored.
554                Task::none()
555            }
556
557            Message::LayoutSaved(_result) => {
558                // Fire-and-forget — errors are silently ignored.
559                Task::none()
560            }
561
562            Message::SessionSaved(_) => {
563                // Fire-and-forget — errors are silently ignored.
564                Task::none()
565            }
566
567            Message::LayoutLoaded(result) => {
568                if let Ok(Some(layout)) = result {
569                    if let Some(w) = layout.sidebar_width {
570                        self.sidebar_width = w;
571                    }
572                    if let Some(w) = layout.commit_log_width {
573                        self.commit_log_width = w;
574                    }
575                    if let Some(h) = layout.staging_height {
576                        self.staging_height = h;
577                    }
578                    if let Some(w) = layout.diff_file_list_width {
579                        self.diff_file_list_width = w;
580                    }
581                    if let Some(expanded) = layout.sidebar_expanded {
582                        self.sidebar_expanded = expanded;
583                    }
584                    if let Some(scale) = layout.ui_scale {
585                        self.ui_scale = scale.clamp(0.5, 2.0);
586                    }
587                }
588                Task::none()
589            }
590
591            Message::Noop => Task::none(),
592        }
593    }
594}