Skip to main content

clankerdiff_ratatui/
diff_commands.rs

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