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