clankerdiff_ratatui/
diff_commands.rs1use crate::{
2 DiffReviewCommand, DiffReviewEvent, DiffReviewState, DiffReviewStatus, FocusPane, InputOutcome,
3 InteractionPhase, KeyBinding, KeyEvent, NavigationPane, ReviewCommand, keybindings,
4 theme_picker::ThemePicker,
5};
6use clankerdiff_core::{CommandContext, RepositoryAction, ReviewCapabilities};
7use std::sync::Arc;
8
9impl DiffReviewState {
10 #[must_use]
11 pub fn command_for_key(&self, key: KeyEvent) -> Option<DiffReviewCommand> {
12 if self.interaction_phase() != InteractionPhase::Browse {
13 return None;
14 }
15 keybindings::binding_for_key(
16 &self.keybindings,
17 key,
18 self.focus == FocusPane::Diff,
19 self.layout().is_split(),
20 )
21 .map(|binding| binding.command.clone())
22 }
23
24 pub(crate) fn help_bindings(&self) -> impl Iterator<Item = &KeyBinding<DiffReviewCommand>> {
25 let context = CommandContext {
26 phase: InteractionPhase::Browse,
27 ..self.command_context()
28 };
29 keybindings::help_bindings(
30 &self.keybindings,
31 context.navigation_available,
32 move |command| self.command_enabled_in(command, &context),
33 )
34 }
35
36 pub(crate) fn footer_hint(&self, width: usize) -> String {
37 keybindings::footer_hint(
38 &self.keybindings,
39 self.focus == FocusPane::Diff,
40 self.layout().is_split(),
41 |command| self.command_enabled(command),
42 &ReviewCommand::ShowHelp.into(),
43 width,
44 )
45 }
46
47 #[must_use]
48 pub fn command_context(&self) -> CommandContext {
49 CommandContext {
50 phase: self.interaction_phase(),
51 capabilities: self.capabilities,
52 repository_pending: self.repository_pending(),
53 document_ready: matches!(self.status, DiffReviewStatus::Ready),
54 navigation_available: !matches!(
55 self.options.navigation,
56 NavigationPane::Hidden | NavigationPane::Width(0)
57 ),
58 themes_available: !self.theme_choices.is_empty(),
59 }
60 }
61
62 pub fn set_capabilities(&mut self, capabilities: ReviewCapabilities) {
63 self.capabilities = capabilities;
64 if !capabilities.repository {
65 self.repository_prompt = None;
66 }
67 self.mark_dirty();
68 }
69
70 #[must_use]
71 pub fn command_enabled(&self, command: &DiffReviewCommand) -> bool {
72 self.command_enabled_in(command, &self.command_context())
73 }
74
75 fn command_enabled_in(&self, command: &DiffReviewCommand, context: &CommandContext) -> bool {
76 command.enabled(context)
77 && (!matches!(command, DiffReviewCommand::CopyReview) || !self.review().is_empty())
78 }
79
80 pub fn handle_command(
81 &mut self,
82 command: impl Into<DiffReviewCommand>,
83 ) -> InputOutcome<DiffReviewEvent> {
84 let outcome = self.dispatch_command(command.into());
85 self.install_deferred();
86 outcome
87 }
88
89 #[expect(
90 clippy::too_many_lines,
91 reason = "Exhaustive command dispatch keeps routing in one place"
92 )]
93 fn dispatch_command(&mut self, command: DiffReviewCommand) -> InputOutcome<DiffReviewEvent> {
94 use DiffReviewCommand as C;
95 use ReviewCommand as R;
96 if !self.command_enabled(&command) {
97 return InputOutcome::Ignored;
98 }
99 match command {
100 C::Focus(pane) => self.focus = pane,
101 C::ToggleFocus => {
102 self.focus = match self.focus {
103 FocusPane::Files => FocusPane::Diff,
104 FocusPane::Diff => FocusPane::Files,
105 }
106 }
107 C::SelectFile(index) => {
108 if !self.select_file(index) {
109 return InputOutcome::Ignored;
110 }
111 }
112 C::MoveSelection(delta) => match self.focus {
113 FocusPane::Files => self.move_drawer_entry(delta),
114 FocusPane::Diff => self.move_row(delta),
115 },
116 C::Scroll { pane, lines } => {
117 match pane {
118 FocusPane::Files => self.scroll_drawer(lines),
119 FocusPane::Diff => self.scroll_patch(lines),
120 }
121 return InputOutcome::Consumed;
122 }
123 C::Page(delta) => self.page(delta),
124 C::First => self.select_boundary(false),
125 C::Last => self.select_boundary(true),
126 C::OpenSelected => {
127 if !self.expand_or_open_drawer_entry() {
128 self.focus = FocusPane::Diff;
129 }
130 }
131 C::CollapseSelected => self.collapse_drawer_entry(),
132 C::ToggleStage => {
133 let Some(action) = self.toggle_stage_action() else {
134 return InputOutcome::Ignored;
135 };
136 return InputOutcome::Emitted(DiffReviewEvent::RepositoryAction(action));
137 }
138 C::BeginCommit => self.begin_commit(),
139 C::BeginDiscard => self.begin_discard(),
140 C::SelectRow(index) => {
141 let previous = (self.session.selected_row(), self.session.selected_side());
142 if !self
143 .session
144 .selected_file_range()
145 .is_some_and(|range| range.contains(&index))
146 || !self.session.select_row(index)
147 {
148 return InputOutcome::Ignored;
149 }
150 if previous == (self.session.selected_row(), self.session.selected_side()) {
151 return InputOutcome::Consumed;
152 }
153 self.request_follow();
154 }
155 C::SelectSide(side) => {
156 let previous = self.session.selected_side();
157 self.session.set_selected_side(side);
158 if previous == self.session.selected_side() {
159 return InputOutcome::Consumed;
160 }
161 self.request_follow();
162 }
163 C::RevealGap(amount) => {
164 self.reveal_selected_gap(amount);
165 return InputOutcome::Consumed;
166 }
167 C::ToggleFullFile => {
168 self.toggle_full_file();
169 return InputOutcome::Consumed;
170 }
171 C::SetViewMode(mode) => {
172 self.set_view_mode(mode);
173 return InputOutcome::Consumed;
174 }
175 C::CycleViewMode => {
176 if self.session.cycle_view_mode() {
177 self.scroll_to_selected_file();
178 }
179 return InputOutcome::Consumed;
180 }
181 C::SubmitReview => {
182 return InputOutcome::Emitted(DiffReviewEvent::SubmitReview(
183 self.session.submission(),
184 ));
185 }
186 C::CopyReview => {
187 return InputOutcome::Emitted(DiffReviewEvent::CopyFormattedReview(
188 self.session.submission().formatted,
189 ));
190 }
191 C::SetScope(scope) => return InputOutcome::Emitted(DiffReviewEvent::SetScope(scope)),
192 C::CycleScope => {
193 return InputOutcome::Emitted(DiffReviewEvent::SetScope(self.scope.next()));
194 }
195 C::Refresh => return InputOutcome::Emitted(DiffReviewEvent::Refresh),
196 C::StageAll => {
197 return InputOutcome::Emitted(DiffReviewEvent::RepositoryAction(
198 RepositoryAction::StageAll,
199 ));
200 }
201 C::UnstageAll => {
202 return InputOutcome::Emitted(DiffReviewEvent::RepositoryAction(
203 RepositoryAction::UnstageAll,
204 ));
205 }
206 C::RepositoryAction(action) => {
207 return InputOutcome::Emitted(DiffReviewEvent::RepositoryAction(action));
208 }
209 C::Review(R::BeginComment | R::EditComment) => {
210 let started = if command == C::Review(R::BeginComment) {
211 self.session.begin_draft(None)
212 } else {
213 self.session.edit_comment_at_selection()
214 };
215 if !started {
216 return InputOutcome::Ignored;
217 }
218 self.request_follow();
219 }
220 C::Review(R::DeleteComment | R::UndoComment) => {
221 let changed = if command == C::Review(R::DeleteComment) {
222 self.session.delete_comment_at_selection()
223 } else {
224 self.session.undo_last_comment()
225 };
226 if !changed {
227 return InputOutcome::Ignored;
228 }
229 self.request_follow();
230 }
231 C::Review(R::SubmitComment) => {
232 self.session.submit_draft();
233 self.cursor_position = None;
234 self.request_follow();
235 }
236 C::Review(R::Cancel) => match self.interaction_phase() {
237 InteractionPhase::Browse => return InputOutcome::Emitted(DiffReviewEvent::Cancel),
238 InteractionPhase::Draft => {
239 self.session.cancel_draft();
240 self.cursor_position = None;
241 self.request_follow();
242 }
243 InteractionPhase::Help => self.help = false,
244 InteractionPhase::RepositoryPrompt => self.repository_prompt = None,
245 InteractionPhase::ThemePicker => {
246 if let Some(picker) = self.theme_picker.take() {
247 self.apply_theme(picker.cancel());
248 }
249 }
250 },
251 C::Review(R::ShowHelp) => {
252 self.help = true;
253 self.help_scroll = 0;
254 }
255 C::Review(R::ScrollHelp(lines)) => {
256 self.help_scroll = self
257 .help_scroll
258 .saturating_add_signed(lines)
259 .min(self.help_bindings().count().saturating_sub(1));
260 }
261 C::Review(R::OpenThemePicker) => {
262 self.theme_picker = ThemePicker::new(&self.theme, Arc::clone(&self.theme_choices));
263 }
264 C::Review(R::SelectTheme(index)) => {
265 let Some(theme) = self
266 .theme_picker
267 .as_mut()
268 .and_then(|picker| picker.select(index))
269 else {
270 return InputOutcome::Ignored;
271 };
272 self.apply_theme(theme);
273 }
274 C::Review(R::MoveTheme(delta)) => {
275 if let Some(picker) = self.theme_picker.as_mut() {
276 let theme = picker.select_relative(delta);
277 self.apply_theme(theme);
278 }
279 }
280 C::Review(R::CommitTheme) => {
281 let Some(picker) = self.theme_picker.take() else {
282 return InputOutcome::Ignored;
283 };
284 let theme = picker.commit();
285 let id = theme.id().clone();
286 self.apply_theme(theme);
287 return InputOutcome::ThemeSelected(id);
288 }
289 }
290 self.mark_dirty();
291 InputOutcome::Consumed
292 }
293}