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