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