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