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