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    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}