1#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub enum PayloadPart {
14 All,
16 Input,
18 Result,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
24pub enum Action {
25 Quit,
27 ToggleHelp,
28 OpenCommandLine,
29 Cancel,
30 Refresh,
31
32 OpenItem,
34 GoUp,
35 GoSchedules,
36 GoWorkflows,
37 JumpBack,
38 JumpForward,
39
40 MoveDown,
42 MoveUp,
43 MoveTop,
44 MoveBottom,
45 HalfPageDown,
46 HalfPageUp,
47
48 EnterInsert,
50 LeaveInsert,
51 EnterVisual,
52 EnterVisualLine,
53
54 OpenSearch,
57 SearchNext,
58 SearchPrev,
59 FindWorkflow,
61 FindEvent,
62 FindPane,
63 FindCommand,
64 FindFilter,
65 FindNamespace,
66 ProblemList,
68
69 YankField,
71 YankRecord,
72 YankPayloadAll,
74 YankPayloadInput,
75 YankPayloadResult,
76 LoadMore,
79
80 ToggleFold,
82 ExpandAll,
83 CollapseAll,
84 TogglePlumbing,
85 NextFailure,
86 PrevFailure,
87 ToggleFollow,
88 ToggleDetail,
89 DetailDown,
90 DetailUp,
91 OpenPipe,
92 OpenEditor,
94
95 SplitRight,
97 SplitDown,
98 CloseWindow,
99 EqualizeWindows,
100 FocusLeft,
101 FocusRight,
102 FocusUp,
103 FocusDown,
104 GrowLeft,
105 GrowRight,
106 GrowUp,
107 GrowDown,
108 CancelWorkflow,
110 TerminateWorkflow,
111 SignalWorkflow,
112 DeleteWorkflow,
113 ResetWorkflow,
114 UpdateWorkflow,
115 PauseSchedule,
116 TriggerSchedule,
117 DeleteSchedule,
118 BackfillSchedule,
119 CreateSchedule,
120
121 NewTab,
122 CloseTab,
123 NextTab,
124 PrevTab,
125 SelectView(char),
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub struct Command {
132 pub id: &'static str,
135 pub title: &'static str,
136 pub group: &'static str,
138 pub action: Action,
139}
140
141pub struct Registry {
142 commands: Vec<Command>,
143}
144
145macro_rules! commands {
146 ($( $id:literal, $group:literal, $title:literal => $action:ident );* $(;)?) => {
147 vec![$(
148 Command { id: $id, title: $title, group: $group, action: Action::$action },
149 )*]
150 };
151}
152
153impl Registry {
154 pub fn builtin() -> Self {
155 let commands = commands! {
156 "app.quit", "Application", "Quit" => Quit;
157 "app.help", "Application", "Toggle help" => ToggleHelp;
158 "app.command-line", "Application", "Open the command line" => OpenCommandLine;
159 "app.cancel", "Application", "Cancel pending input" => Cancel;
160 "app.refresh", "Application", "Reload from the server" => Refresh;
161
162 "motion.down", "Motion", "Move down" => MoveDown;
163 "motion.up", "Motion", "Move up" => MoveUp;
164 "motion.top", "Motion", "Go to first item" => MoveTop;
165 "motion.bottom", "Motion", "Go to last item" => MoveBottom;
166 "motion.half-down", "Motion", "Half page down" => HalfPageDown;
167 "motion.half-up", "Motion", "Half page up" => HalfPageUp;
168
169 "mode.insert", "Mode", "Enter Insert mode" => EnterInsert;
170 "mode.normal", "Mode", "Leave Insert mode" => LeaveInsert;
171 "mode.visual", "Mode", "Enter Visual mode" => EnterVisual;
172 "mode.visual-line", "Mode", "Enter Visual Line mode" => EnterVisualLine;
173
174 "nav.open", "Navigation", "Open the focused item" => OpenItem;
175 "nav.up", "Navigation", "Go up a level" => GoUp;
176 "nav.schedules", "Navigation", "Go to schedules" => GoSchedules;
177 "nav.workflows", "Navigation", "Go to workflows" => GoWorkflows;
178 "nav.jump-back", "Navigation", "Jump back" => JumpBack;
179 "nav.jump-forward", "Navigation", "Jump forward" => JumpForward;
180
181 "search.open", "Find", "Search within this view" => OpenSearch;
182 "search.next", "Find", "Next match" => SearchNext;
183 "search.previous", "Find", "Previous match" => SearchPrev;
184 "find.workflow", "Find", "Find a workflow" => FindWorkflow;
185 "find.event", "Find", "Find an event here" => FindEvent;
186 "find.pane", "Find", "Find an open pane" => FindPane;
187 "find.command", "Find", "Find a command" => FindCommand;
188 "find.filter", "Find", "Build a query filter" => FindFilter;
189 "find.namespace", "Find", "Switch namespace" => FindNamespace;
190 "list.problems", "Find", "Failed and stuck workflows" => ProblemList;
191
192 "yank.field", "Yank", "Yank the focused value" => YankField;
193 "yank.record", "Yank", "Yank the row as JSON" => YankRecord;
194 "yank.payload", "Yank", "Yank every payload here" => YankPayloadAll;
195 "yank.payload-input", "Yank", "Yank the input payloads" => YankPayloadInput;
196 "yank.payload-result","Yank", "Yank the result payload" => YankPayloadResult;
197
198 "list.more", "List", "Load the next page" => LoadMore;
199
200 "history.fold", "History", "Fold a group open or shut" => ToggleFold;
201 "history.expand-all", "History", "Expand every group" => ExpandAll;
202 "history.collapse-all","History", "Collapse every group" => CollapseAll;
203 "history.plumbing", "History", "Show/hide workflow tasks" => TogglePlumbing;
204 "history.next-failure","History", "Jump to the next failure" => NextFailure;
205 "history.prev-failure","History", "Jump to the previous failure" => PrevFailure;
206 "history.follow", "History", "Follow, tail a running workflow" => ToggleFollow;
207 "history.detail", "History", "Show the payloads under the cursor" => ToggleDetail;
208 "history.detail-down","History", "Scroll the payload pane down" => DetailDown;
209 "history.detail-up", "History", "Scroll the payload pane up" => DetailUp;
210 "payload.pipe", "History", "Pipe payloads through a command" => OpenPipe;
211 "payload.edit", "History", "Open the payloads in $EDITOR" => OpenEditor;
212
213 "window.split-right", "Windows", "Split side by side" => SplitRight;
214 "window.split-down", "Windows", "Split above and below" => SplitDown;
215 "window.close", "Windows", "Close this window" => CloseWindow;
216 "window.equalize", "Windows", "Give windows equal space" => EqualizeWindows;
217 "window.focus-left", "Windows", "Focus the window left" => FocusLeft;
218 "window.focus-right", "Windows", "Focus the window right" => FocusRight;
219 "window.focus-up", "Windows", "Focus the window above" => FocusUp;
220 "window.focus-down", "Windows", "Focus the window below" => FocusDown;
221 "window.grow-left", "Windows", "Widen to the left" => GrowLeft;
222 "window.grow-right", "Windows", "Widen to the right" => GrowRight;
223 "window.grow-up", "Windows", "Grow upwards" => GrowUp;
224 "window.grow-down", "Windows", "Grow downwards" => GrowDown;
225
226 "workflow.cancel", "Mutate", "Cancel this workflow" => CancelWorkflow;
227 "workflow.terminate", "Mutate", "Terminate this workflow" => TerminateWorkflow;
228 "workflow.signal", "Mutate", "Signal this workflow" => SignalWorkflow;
229 "workflow.delete", "Mutate", "Delete this workflow" => DeleteWorkflow;
230 "workflow.reset", "Mutate", "Reset to the event here" => ResetWorkflow;
231 "workflow.update", "Mutate", "Send an update" => UpdateWorkflow;
232 "schedule.pause", "Mutate", "Pause or resume a schedule" => PauseSchedule;
233 "schedule.trigger", "Mutate", "Run a schedule now" => TriggerSchedule;
234 "schedule.delete", "Mutate", "Delete this schedule" => DeleteSchedule;
235 "schedule.backfill", "Mutate", "Backfill a time range" => BackfillSchedule;
236 "schedule.create", "Mutate", "Create a schedule" => CreateSchedule;
237
238 "tab.new", "Tabs", "Open a tab" => NewTab;
239 "tab.close", "Tabs", "Close this tab" => CloseTab;
240 "tab.next", "Tabs", "Next tab" => NextTab;
241 "tab.previous", "Tabs", "Previous tab" => PrevTab;
242 };
243 Self { commands }
244 }
245
246 pub fn add_views(&mut self, views: &[crate::config::SavedView]) {
254 for v in views {
255 let id: &'static str = Box::leak(format!("view.{}", v.key).into_boxed_str());
256 let title: &'static str = Box::leak(v.name.clone().into_boxed_str());
257 self.commands.retain(|c| c.id != id);
258 self.commands.push(Command {
259 id,
260 title,
261 group: "Views",
262 action: Action::SelectView(v.key),
263 });
264 }
265 }
266
267 pub fn all(&self) -> &[Command] {
268 &self.commands
269 }
270
271 pub fn get(&self, id: &str) -> Option<&Command> {
272 self.commands.iter().find(|c| c.id == id)
273 }
274
275 pub fn search(&self, query: &str) -> Vec<&Command> {
278 let q = query.trim().to_ascii_lowercase();
279 if q.is_empty() {
280 let mut all: Vec<&Command> = self.commands.iter().collect();
281 all.sort_by_key(|c| c.id);
282 return all;
283 }
284 let mut hits: Vec<(&Command, i32)> = self
288 .commands
289 .iter()
290 .filter_map(|c| {
291 let by_id = crate::fuzzy::match_score(&q, c.id).map(|m| m.score);
294 let by_title = crate::fuzzy::match_score(&q, c.title).map(|m| m.score);
295 by_id.max(by_title).map(|s| (c, s))
296 })
297 .collect();
298 hits.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.id.cmp(b.0.id)));
299 hits.into_iter().map(|(c, _)| c).collect()
300 }
301
302 pub fn groups(&self) -> Vec<&'static str> {
304 let mut seen = Vec::new();
305 for c in &self.commands {
306 if !seen.contains(&c.group) {
307 seen.push(c.group);
308 }
309 }
310 seen
311 }
312}
313
314impl Default for Registry {
315 fn default() -> Self {
316 Self::builtin()
317 }
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 #[test]
325 fn ids_are_unique() {
326 let r = Registry::builtin();
327 let mut ids: Vec<_> = r.all().iter().map(|c| c.id).collect();
328 ids.sort_unstable();
329 let before = ids.len();
330 ids.dedup();
331 assert_eq!(before, ids.len(), "duplicate command id in the registry");
332 }
333
334 #[test]
335 fn every_action_is_registered() {
336 let r = Registry::builtin();
339 let actions: std::collections::HashSet<_> = r.all().iter().map(|c| c.action).collect();
340 assert_eq!(
341 actions.len(),
342 r.all().len(),
343 "two commands share an Action; each should be distinct"
344 );
345 }
346
347 #[test]
348 fn lookup_by_id() {
349 let r = Registry::builtin();
350 assert_eq!(r.get("motion.down").unwrap().action, Action::MoveDown);
351 assert!(r.get("nope").is_none());
352 }
353
354 #[test]
355 fn search_prefers_prefix_matches() {
356 let r = Registry::builtin();
357 let hits = r.search("motion.");
358 assert!(hits.iter().all(|c| c.id.starts_with("motion.")));
359 assert!(hits.len() >= 6);
360 }
361
362 #[test]
363 fn search_matches_subsequences_and_titles() {
364 let r = Registry::builtin();
365 assert!(r.search("mdown").iter().any(|c| c.id == "motion.down"));
366 assert!(r.search("quit").iter().any(|c| c.id == "app.quit"));
367 }
368
369 #[test]
370 fn saved_views_become_real_commands() {
371 use crate::config::SavedView;
372 let mut r = Registry::builtin();
373 r.add_views(&[
374 SavedView {
375 key: '1',
376 name: "Running".into(),
377 query: "ExecutionStatus = 'Running'".into(),
378 },
379 SavedView {
380 key: '2',
381 name: "Broken".into(),
382 query: "ExecutionStatus = 'Failed'".into(),
383 },
384 ]);
385
386 let one = r.get("view.1").expect("view.1 should be registered");
387 assert_eq!(one.action, Action::SelectView('1'));
388 assert_eq!(one.title, "Running", "the view's own name is its title");
389 assert_eq!(one.group, "Views");
390 assert_eq!(r.get("view.2").unwrap().action, Action::SelectView('2'));
391 assert!(r.search("view.").iter().any(|c| c.id == "view.1"));
393 }
394
395 #[test]
396 fn reloading_views_replaces_rather_than_duplicates() {
397 use crate::config::SavedView;
398 let mut r = Registry::builtin();
399 let view = |name: &str| SavedView {
400 key: '1',
401 name: name.into(),
402 query: String::new(),
403 };
404 r.add_views(&[view("First")]);
405 r.add_views(&[view("Second")]);
406
407 let hits: Vec<_> = r.all().iter().filter(|c| c.id == "view.1").collect();
408 assert_eq!(hits.len(), 1, "a reloaded view must not register twice");
409 assert_eq!(hits[0].title, "Second");
410 }
411
412 #[test]
413 fn empty_search_returns_everything_sorted() {
414 let r = Registry::builtin();
415 let hits = r.search(" ");
416 assert_eq!(hits.len(), r.all().len());
417 assert!(hits.windows(2).all(|w| w[0].id <= w[1].id));
418 }
419}