git_tailor/views/move_select.rs
1// Copyright 2026 Thomas Johannesson
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15// Move commit target selection — key handling only; rendering is done via
16// the commit list (separator row injection + footer).
17
18use crate::app::{AppAction, AppMode, AppState, KeyCommand};
19
20/// Handle an action while in MoveSelect mode.
21///
22/// The user navigates an insertion cursor between commits. The cursor
23/// (`insert_before`) represents the position where the source commit will
24/// be placed. Arrow keys move the insertion point; Enter confirms; Esc
25/// cancels.
26pub fn handle_key(action: KeyCommand, app: &mut AppState) -> AppAction {
27 let (source_index, insert_before) = match app.mode {
28 AppMode::MoveSelect {
29 source_index,
30 insert_before,
31 } => (source_index, insert_before),
32 _ => return AppAction::Handled,
33 };
34
35 // Valid insertion positions: 0..=commits.len(), excluding source_index.
36 // Position N means "insert before commit N"; position commits.len() means
37 // "insert after the last commit" (i.e. move to HEAD).
38 let max_insert = app.commits.len();
39
40 // Both source_index and source_index + 1 are no-op positions:
41 // "insert before self" and "insert after self" both leave the commit
42 // in the same place.
43 let is_noop = |pos: usize| pos == source_index || pos == source_index + 1;
44
45 match action {
46 KeyCommand::MoveUp => {
47 let mut next = if app.reverse {
48 insert_before.saturating_add(1).min(max_insert)
49 } else {
50 insert_before.saturating_sub(1)
51 };
52 for _ in 0..2 {
53 if is_noop(next) {
54 next = if app.reverse {
55 next.saturating_add(1).min(max_insert)
56 } else {
57 next.saturating_sub(1)
58 };
59 }
60 }
61 if is_noop(next) {
62 next = insert_before;
63 }
64 app.mode = AppMode::MoveSelect {
65 source_index,
66 insert_before: next,
67 };
68 AppAction::Handled
69 }
70 KeyCommand::MoveDown => {
71 let mut next = if app.reverse {
72 insert_before.saturating_sub(1)
73 } else {
74 insert_before.saturating_add(1).min(max_insert)
75 };
76 for _ in 0..2 {
77 if is_noop(next) {
78 next = if app.reverse {
79 next.saturating_sub(1)
80 } else {
81 next.saturating_add(1).min(max_insert)
82 };
83 }
84 }
85 if is_noop(next) {
86 next = insert_before;
87 }
88 app.mode = AppMode::MoveSelect {
89 source_index,
90 insert_before: next,
91 };
92 AppAction::Handled
93 }
94 KeyCommand::Confirm => {
95 if is_noop(insert_before) {
96 app.set_error_message("Commit is already at this position");
97 return AppAction::Handled;
98 }
99
100 let source = &app.commits[source_index];
101 if source.oid == "staged" || source.oid == "unstaged" {
102 app.set_error_message("Cannot move staged/unstaged changes");
103 return AppAction::Handled;
104 }
105
106 let source_oid = source.oid.clone();
107
108 // insert_before is the commit-list index where the separator sits.
109 // The source should be placed *after* the commit at insert_before - 1,
110 // or after the reference point if insert_before == 0.
111 let insert_after_oid = if insert_before == 0 {
112 app.reference_oid.clone()
113 } else {
114 let idx = (insert_before - 1).min(app.commits.len().saturating_sub(1));
115 app.commits[idx].oid.clone()
116 };
117
118 app.mode = AppMode::CommitList;
119
120 AppAction::ExecuteMove {
121 source_oid,
122 insert_after_oid,
123 }
124 }
125 KeyCommand::ShowHelp => {
126 app.toggle_help();
127 AppAction::Handled
128 }
129 KeyCommand::Quit => {
130 app.cancel_move_select();
131 AppAction::Handled
132 }
133 _ => AppAction::Handled,
134 }
135}