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