Skip to main content

tmprl_core/
command.rs

1//! The command registry.
2//!
3//! Every user-visible action is registered here exactly once. The keymap, the `:` command
4//! line, the which-key popup and the help overlay all read this one table, so they cannot
5//! drift apart: a command that exists is reachable and discoverable by construction.
6//!
7//! Commands carry an [`Action`] rather than a function pointer. Dispatch lives in
8//! `tmprl-tui`, which keeps this crate free of any application or terminal types, and
9//! makes the match on `Action` exhaustive, so a new command cannot be silently unhandled.
10
11/// Which payloads a yank takes.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub enum PayloadPart {
14    /// Everything the row carries.
15    All,
16    /// Arguments only: `input`, or `input[0]`, `input[1]` … when there are several.
17    Input,
18    /// The return value only.
19    Result,
20}
21
22/// What a command does. `tmprl-tui` matches on this exhaustively.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
24pub enum Action {
25    // Application
26    Quit,
27    ToggleHelp,
28    OpenCommandLine,
29    Cancel,
30    Refresh,
31
32    // Navigation between screens
33    OpenItem,
34    GoUp,
35    GoSchedules,
36    GoWorkflows,
37    JumpBack,
38    JumpForward,
39
40    // Motion
41    MoveDown,
42    MoveUp,
43    MoveTop,
44    MoveBottom,
45    HalfPageDown,
46    HalfPageUp,
47
48    // Modes
49    EnterInsert,
50    LeaveInsert,
51    EnterVisual,
52    EnterVisualLine,
53
54    // Finding. Search moves the cursor within the rows already loaded; it never refetches,
55    // which is what distinguishes it from the visibility query.
56    OpenSearch,
57    SearchNext,
58    SearchPrev,
59    /// The `<leader>f` family. Each opens the one picker over a different set of items.
60    FindWorkflow,
61    FindEvent,
62    FindPane,
63    FindCommand,
64    FindFilter,
65    FindNamespace,
66    /// The failed-and-stuck list, `<leader>xx`. A query preset, not a screen of its own.
67    ProblemList,
68
69    // Data
70    YankField,
71    YankRecord,
72    /// Yank the payloads under the cursor; the variant picks the subset.
73    YankPayloadAll,
74    YankPayloadInput,
75    YankPayloadResult,
76    /// Fetch the next page of the workflow list. Driven by scrolling rather than by a key,
77    /// but it is a command so that `:` and macros reach it like anything else.
78    LoadMore,
79
80    // History
81    ToggleFold,
82    ExpandAll,
83    CollapseAll,
84    TogglePlumbing,
85    NextFailure,
86    PrevFailure,
87    ToggleFollow,
88    ToggleDetail,
89    DetailDown,
90    DetailUp,
91    OpenPipe,
92    /// Open what is under the cursor in `$EDITOR`, `<leader>e`.
93    OpenEditor,
94
95    // Windows and tabs
96    SplitRight,
97    SplitDown,
98    CloseWindow,
99    EqualizeWindows,
100    FocusLeft,
101    FocusRight,
102    FocusUp,
103    FocusDown,
104    GrowLeft,
105    GrowRight,
106    GrowUp,
107    GrowDown,
108    // Mutations. Each opens a confirmation; none acts on its own.
109    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    /// Apply the saved view bound to this digit. Carries the digit because the views come
126    /// from `views.toml` and cannot be enumerated at compile time.
127    SelectView(char),
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub struct Command {
132    /// Stable identifier. This is what `keys.toml`, macros and `--exec` refer to, so it is
133    /// part of the public interface and must not change casually.
134    pub id: &'static str,
135    pub title: &'static str,
136    /// Grouping for the help overlay.
137    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    /// Register the saved views from `views.toml` as ordinary commands.
247    ///
248    /// Views are user data, so their ids and titles are not known at compile time. They are
249    /// leaked deliberately: a `Registry` is built once at startup and lives for the whole
250    /// process, so this is a bounded, one-off allocation, and it is what lets a saved view
251    /// be a first-class command, reachable from `:`, the help overlay and a macro, rather
252    /// than a special case wired past the registry.
253    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    /// Subsequence match over the id and title, ranked so that shorter ids win ties. Good
276    /// enough for a command line where the candidate set is small and known.
277    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        // Scored by the same matcher the pickers use, so `:` and `<leader>ff` rank the
285        // same way. Before, this was a bare subsequence test with a prefix-first sort,
286        // which is adequate for eighty command ids and not for anything longer.
287        let mut hits: Vec<(&Command, i32)> = self
288            .commands
289            .iter()
290            .filter_map(|c| {
291                // An id and a title are two ways of naming the same command, so the better
292                // of the two scores is the command's score.
293                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    /// Every group name, in first-registered order, the help overlay renders in this order.
303    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        // Adding an Action without a Command would make it unreachable from the command
337        // line, which defeats the point of the registry.
338        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        // Reachable from the command line like anything else.
392        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}