1use crate::app::{App, Tab};
10use crate::modal::{ConfigSetField, ConfigSetModal, Modal};
11use crate::palette;
12use crate::widgets::{EditMode, Editor, VimAction, VimMode};
13use cli_core::{CommandNode, command_tree};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum Intent {
17 Quit,
18 NextTab,
19 PreviousTab,
20 SelectTab(Tab),
21 OpenCommandLine,
22 CloseTopmost,
23 SubmitCommandLine,
24 InsertChar(char),
25 DeleteBackward,
26 MoveLeft,
27 MoveRight,
28 MoveHome,
29 MoveEnd,
30 KillToEnd,
31 KillWordBackward,
32 Vim(VimAction),
33 ToggleEditMode,
34 OpenHelp,
35}
36
37pub fn apply(app: &mut App, intent: Intent) {
40 if handle_modal(app, intent) {
41 return;
42 }
43 if app.command_line_active {
44 handle_command_line(app, intent);
45 return;
46 }
47 handle_tab(app, intent);
48}
49
50fn handle_modal(app: &mut App, intent: Intent) -> bool {
51 if app.modals.is_empty() {
52 return false;
53 }
54 if matches!(intent, Intent::CloseTopmost) {
55 app.modals.pop();
56 return true;
57 }
58 if let Some(top) = app.modals.top_mut() {
59 apply_modal_intent(top, intent);
60 }
61 true
62}
63
64fn apply_modal_intent(modal: &mut Modal, intent: Intent) {
65 let Modal::ConfigSet(form) = modal else {
66 return;
67 };
68 if matches!(intent, Intent::NextTab | Intent::PreviousTab) {
69 form.focus = match form.focus {
70 ConfigSetField::Name => ConfigSetField::Value,
71 ConfigSetField::Value => ConfigSetField::Name,
72 };
73 return;
74 }
75 let editor = match form.focus {
76 ConfigSetField::Name => &mut form.name,
77 ConfigSetField::Value => &mut form.value,
78 };
79 match intent {
80 Intent::InsertChar(c) => editor.insert_char(c),
81 Intent::DeleteBackward => editor.delete_backward(),
82 Intent::MoveLeft => editor.move_left(),
83 Intent::MoveRight => editor.move_right(),
84 Intent::MoveHome => editor.move_home(),
85 Intent::MoveEnd => editor.move_end(),
86 Intent::KillToEnd => editor.kill_to_end(),
87 Intent::KillWordBackward => editor.kill_word_backward(),
88 Intent::Vim(action) => editor.vim_action(action),
89 _ => {}
90 }
91}
92
93fn handle_command_line(app: &mut App, intent: Intent) {
94 let editor = &mut app.command_line;
95 match intent {
96 Intent::CloseTopmost => {
97 if app.edit_mode == EditMode::Vim && editor.vim_mode() == VimMode::Insert {
98 editor.enter_normal_mode();
99 } else {
100 app.command_line_active = false;
101 }
102 }
103 Intent::SubmitCommandLine => {
104 let buffer = editor.buffer().to_string();
105 app.close_command_line();
106 submit_palette(app, &buffer);
107 }
108 Intent::InsertChar(c) => editor.insert_char(c),
109 Intent::DeleteBackward => editor.delete_backward(),
110 Intent::MoveLeft => editor.move_left(),
111 Intent::MoveRight => editor.move_right(),
112 Intent::MoveHome => editor.move_home(),
113 Intent::MoveEnd => editor.move_end(),
114 Intent::KillToEnd => editor.kill_to_end(),
115 Intent::KillWordBackward => editor.kill_word_backward(),
116 Intent::Vim(action) => editor.vim_action(action),
117 _ => {}
118 }
119}
120
121fn submit_palette(app: &mut App, input: &str) {
126 let query = palette::parse(input);
127 if query.path.is_empty() {
128 app.set_status("");
129 return;
130 }
131 let tree = command_tree();
132 match palette::resolve(&tree, &query) {
133 Some(node) => apply_resolved_command(app, node, &query.args),
134 None => app.set_status(format!("unknown command: {}", query.path.join(" "))),
135 }
136}
137
138fn apply_resolved_command(app: &mut App, node: &CommandNode, args: &[(String, String)]) {
139 match node.name.as_str() {
143 "set" => open_config_set_modal(app, args),
144 "version" => app.set_status("nomisync automation CLI carries `version` for now"),
145 _ => app.set_status(format!("resolved: {}", node.name)),
146 }
147}
148
149fn open_config_set_modal(app: &mut App, args: &[(String, String)]) {
150 let mut name = Editor::new(app.edit_mode);
151 let mut value = Editor::new(app.edit_mode);
152 for (k, v) in args {
153 match k.as_str() {
154 "name" => name = Editor::with_buffer(app.edit_mode, v.clone()),
155 "value" => value = Editor::with_buffer(app.edit_mode, v.clone()),
156 _ => {}
157 }
158 }
159 app.modals.push(Modal::ConfigSet(ConfigSetModal {
160 name,
161 value,
162 focus: ConfigSetField::Name,
163 }));
164}
165
166fn handle_tab(app: &mut App, intent: Intent) {
167 match intent {
168 Intent::Quit => app.request_quit(),
169 Intent::NextTab => app.next_tab(),
170 Intent::PreviousTab => app.previous_tab(),
171 Intent::SelectTab(t) => app.switch_tab(t),
172 Intent::OpenCommandLine => app.open_command_line(),
173 Intent::OpenHelp => app.modals.push(Modal::Help),
174 Intent::ToggleEditMode => {
175 let next = match app.edit_mode {
176 EditMode::Emacs => EditMode::Vim,
177 EditMode::Vim => EditMode::Emacs,
178 };
179 app.set_edit_mode(next);
180 }
181 _ => {}
182 }
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188 use sqlx::types::Uuid;
189
190 fn make_app() -> App {
191 App::new(Uuid::new_v4(), EditMode::Emacs)
192 }
193
194 #[test]
195 fn quit_intent_sets_quit_flag() {
196 let mut app = make_app();
197 apply(&mut app, Intent::Quit);
198 assert!(app.should_quit);
199 }
200
201 #[test]
202 fn next_tab_intent_advances_tab() {
203 let mut app = make_app();
204 app.active_tab = Tab::Accounts;
205 apply(&mut app, Intent::NextTab);
206 assert_eq!(app.active_tab, Tab::Transactions);
207 }
208
209 #[test]
210 fn select_tab_intent_jumps_directly() {
211 let mut app = make_app();
212 apply(&mut app, Intent::SelectTab(Tab::Reports));
213 assert_eq!(app.active_tab, Tab::Reports);
214 }
215
216 #[test]
217 fn open_command_line_activates_and_accepts_input() {
218 let mut app = make_app();
219 apply(&mut app, Intent::OpenCommandLine);
220 assert!(app.command_line_active);
221 apply(&mut app, Intent::InsertChar('v'));
222 apply(&mut app, Intent::InsertChar('x'));
223 assert_eq!(app.command_line.buffer(), "vx");
224 }
225
226 #[test]
227 fn command_line_escape_exits_when_already_in_normal_mode() {
228 let mut app = make_app();
229 app.set_edit_mode(EditMode::Vim);
230 apply(&mut app, Intent::OpenCommandLine);
231 app.command_line.enter_normal_mode();
232 apply(&mut app, Intent::CloseTopmost);
233 assert!(!app.command_line_active);
234 }
235
236 #[test]
237 fn command_line_escape_first_drops_vim_to_normal() {
238 let mut app = make_app();
239 app.set_edit_mode(EditMode::Vim);
240 apply(&mut app, Intent::OpenCommandLine);
241 apply(&mut app, Intent::InsertChar('x'));
242 assert_eq!(app.command_line.vim_mode(), VimMode::Insert);
243 apply(&mut app, Intent::CloseTopmost);
244 assert!(
245 app.command_line_active,
246 "first Esc should keep cmdline open"
247 );
248 assert_eq!(app.command_line.vim_mode(), VimMode::Normal);
249 }
250
251 #[test]
252 fn submit_command_line_records_buffer_and_closes() {
253 let mut app = make_app();
254 apply(&mut app, Intent::OpenCommandLine);
255 for c in "version".chars() {
256 apply(&mut app, Intent::InsertChar(c));
257 }
258 apply(&mut app, Intent::SubmitCommandLine);
259 assert!(!app.command_line_active);
260 assert!(
261 app.status.contains("version"),
262 "status should report the resolved command, got {}",
263 app.status
264 );
265 }
266
267 #[test]
268 fn submit_command_line_with_unknown_path_surfaces_error() {
269 let mut app = make_app();
270 apply(&mut app, Intent::OpenCommandLine);
271 for c in "bogus-cmd".chars() {
272 apply(&mut app, Intent::InsertChar(c));
273 }
274 apply(&mut app, Intent::SubmitCommandLine);
275 assert!(app.status.contains("unknown"));
276 }
277
278 #[test]
279 fn submit_config_set_opens_form_modal() {
280 let mut app = make_app();
281 apply(&mut app, Intent::OpenCommandLine);
282 for c in "config set name=locale value=en".chars() {
283 apply(&mut app, Intent::InsertChar(c));
284 }
285 apply(&mut app, Intent::SubmitCommandLine);
286 assert!(!app.modals.is_empty());
287 match app.modals.top() {
288 Some(Modal::ConfigSet(form)) => {
289 assert_eq!(form.name.buffer(), "locale");
290 assert_eq!(form.value.buffer(), "en");
291 }
292 other => panic!("expected ConfigSet modal, got {other:?}"),
293 }
294 }
295
296 #[test]
297 fn open_help_pushes_a_modal() {
298 let mut app = make_app();
299 apply(&mut app, Intent::OpenHelp);
300 assert!(!app.modals.is_empty());
301 assert!(matches!(app.modals.top(), Some(Modal::Help)));
302 }
303
304 #[test]
305 fn close_topmost_pops_modal_before_touching_tabs() {
306 let mut app = make_app();
307 apply(&mut app, Intent::OpenHelp);
308 apply(&mut app, Intent::Quit);
309 assert!(
310 !app.should_quit,
311 "quit should be swallowed by the modal layer"
312 );
313 apply(&mut app, Intent::CloseTopmost);
314 assert!(app.modals.is_empty());
315 apply(&mut app, Intent::Quit);
316 assert!(app.should_quit);
317 }
318
319 #[test]
320 fn toggle_edit_mode_flips_emacs_vim() {
321 let mut app = make_app();
322 assert_eq!(app.edit_mode, EditMode::Emacs);
323 apply(&mut app, Intent::ToggleEditMode);
324 assert_eq!(app.edit_mode, EditMode::Vim);
325 apply(&mut app, Intent::ToggleEditMode);
326 assert_eq!(app.edit_mode, EditMode::Emacs);
327 }
328}