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