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