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