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