Skip to main content

clankerdiff_ratatui/
diff_commands.rs

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