Skip to main content

taba_cli/tui/menu/
mod.rs

1//! The menu of an item (`Enter` on a row of a list): the actions that can be done to the selected topic, token,
2//! profile or node, each named after what it acts on (`Edit the topic`, `Publish a message`), so a topic is never
3//! mistaken for a message. The keys of the screens keep working; the menu only lists them.
4
5use ratatui::crossterm::event::{KeyCode, KeyEvent};
6
7use crate::tui::api::Severity;
8use crate::tui::effect::Effect;
9use crate::tui::keymap::{self, Action, Scope};
10use crate::tui::model::{short_id, Menu, Modal, Model, Pane, Tab};
11use crate::tui::screens::cluster as cluster_screen;
12use crate::tui::screens::{profiles, tokens, topics};
13use crate::tui::update::dispatch;
14
15#[cfg(test)]
16mod tests;
17
18const TOPIC: &[Action] = &[
19    Action::TopicSummary,
20    Action::TopicCharts,
21    Action::MsgPublish,
22    Action::MsgReceive,
23    Action::MsgPrune,
24    Action::TopicEdit,
25    Action::TopicDelete,
26];
27const TOKEN: &[Action] = &[Action::TokenEdit, Action::TokenClone, Action::TokenRevoke];
28const PROFILE: &[Action] = &[
29    Action::ProfileUse,
30    Action::ProfileEdit,
31    Action::ProfileDefault,
32    Action::ProfileTest,
33    Action::ProfileRemove,
34];
35const NODE: &[Action] = &[
36    Action::Detail,
37    Action::Monitor,
38    Action::Drain,
39    Action::Return,
40    Action::FlushNode,
41    Action::FlushAll,
42    Action::StopCluster,
43];
44
45/// The catalog key of the words that name an action in the menu.
46pub fn label_key(action: Action) -> &'static str {
47    match action {
48        Action::TopicSummary => "menu.topic-summary",
49        Action::TopicCharts => "menu.topic-charts",
50        Action::TopicMessages => "menu.topic-messages",
51        Action::MsgPublish => "menu.msg-publish",
52        Action::MsgReceive => "menu.msg-receive",
53        Action::MsgPrune => "menu.msg-prune",
54        Action::TopicEdit => "menu.topic-edit",
55        Action::TopicDelete => "menu.topic-delete",
56        Action::TokenEdit => "menu.token-edit",
57        Action::TokenClone => "menu.token-clone",
58        Action::TokenRevoke => "menu.token-revoke",
59        Action::ProfileUse => "menu.profile-use",
60        Action::ProfileEdit => "menu.profile-edit",
61        Action::ProfileDefault => "menu.profile-default",
62        Action::ProfileTest => "menu.profile-test",
63        Action::ProfileRemove => "menu.profile-remove",
64        Action::Detail => "menu.node-detail",
65        Action::Monitor => "menu.node-monitor",
66        Action::Drain => "menu.node-drain",
67        Action::Return => "menu.node-return",
68        Action::FlushNode => "menu.node-flush",
69        Action::FlushAll => "menu.node-flush-all",
70        Action::StopCluster => "menu.node-stop",
71        _ => "hint.more",
72    }
73}
74
75fn scope_of_tab(tab: Tab) -> Scope {
76    match tab {
77        Tab::Cluster => Scope::Cluster,
78        Tab::Topics => Scope::Topics,
79        Tab::Tokens => Scope::Tokens,
80        Tab::Profiles => Scope::Profiles,
81    }
82}
83
84/// The keys of an action on the list the menu was opened from; none when the key belongs to another view (inside
85/// the messages `p` publishes, but on the list it pauses).
86pub fn shortcut(tab: Tab, action: Action) -> Option<&'static str> {
87    let scope = scope_of_tab(tab);
88    keymap::BINDINGS
89        .iter()
90        .find(|b| b.action == action && b.scope == scope)
91        .map(|b| b.label)
92}
93
94/// `Enter` on a list: opens the menu of the selected item, or says why there is none.
95pub fn open(m: &mut Model) -> Vec<Effect> {
96    let tx = m.tx();
97    let (title, items): (String, &'static [Action]) = match m.tab {
98        Tab::Topics => match topics::selected(m) {
99            Some(t) => (tx.tw("menu.title-topic", &[("name", &t.name)]), TOPIC),
100            None => return refuse(m, "gate.no-topic"),
101        },
102        Tab::Tokens => match tokens::selected(m) {
103            Some(t) => (tx.tw("menu.title-token", &[("name", &t.name)]), TOKEN),
104            None => return refuse(m, "gate.no-token"),
105        },
106        Tab::Profiles => match profiles::selected(m) {
107            Some((name, _)) => (tx.tw("menu.title-profile", &[("name", &name)]), PROFILE),
108            None => return refuse(m, "gate.no-profile"),
109        },
110        Tab::Cluster => match cluster_screen::selected_row(m) {
111            Some(row) => (
112                tx.tw("menu.title-node", &[("name", &short_id(&row.id))]),
113                NODE,
114            ),
115            None => return refuse(m, "gate.no-node"),
116        },
117    };
118    m.modal = Some(Modal::Menu(Menu {
119        title,
120        items: items.to_vec(),
121        selected: 0,
122    }));
123    Vec::new()
124}
125
126fn refuse(m: &mut Model, key: &str) -> Vec<Effect> {
127    let text = m.tx().t(key);
128    m.notify(Severity::Warning, text);
129    Vec::new()
130}
131
132/// Why an entry cannot run now, as a catalog key; `None` means it can.
133pub fn gate(m: &Model, action: Action) -> Option<&'static str> {
134    match m.tab {
135        Tab::Topics => topic_gate(m, action),
136        Tab::Tokens => tokens::gate(m, action),
137        Tab::Profiles => None,
138        Tab::Cluster => cluster_screen::gate(m, action).map(|g| g.key()),
139    }
140}
141
142/// The checks of the entries of a topic. The messages ones are the same the open view makes, asked before opening it.
143fn topic_gate(m: &Model, action: Action) -> Option<&'static str> {
144    if matches!(action, Action::TopicEdit | Action::TopicDelete) {
145        return topics::gate(m, action);
146    }
147    let me = m.me.data.as_ref()?;
148    let topic = topics::selected(m)?;
149    let distributor = topic.is_distributor();
150    match action {
151        Action::TopicCharts if distributor => Some("gate.topic-no-charts"),
152        Action::MsgPublish if topic.dead_letter => Some("gate.topic-read-only"),
153        Action::MsgPublish if !me.can("publish") => Some("gate.need-publish"),
154        Action::MsgReceive | Action::MsgPrune if distributor => Some("gate.topic-no-receive"),
155        Action::MsgReceive if !me.can("consume") => Some("gate.need-consume"),
156        Action::MsgPrune if !me.can("manage") => Some("gate.need-manage"),
157        _ => None,
158    }
159}
160
161/// The keys of the open menu: the arrows choose, `Enter` runs the entry, `Esc` closes.
162pub fn key(m: &mut Model, event: KeyEvent, mut menu: Menu) -> Vec<Effect> {
163    let count = menu.items.len();
164    match event.code {
165        KeyCode::Esc | KeyCode::Char('q') => {
166            m.modal = None;
167            Vec::new()
168        }
169        KeyCode::Up | KeyCode::Char('k') => {
170            menu.selected = cluster_screen::moved(menu.selected, -1, count);
171            m.modal = Some(Modal::Menu(menu));
172            Vec::new()
173        }
174        KeyCode::Down | KeyCode::Char('j') => {
175            menu.selected = cluster_screen::moved(menu.selected, 1, count);
176            m.modal = Some(Modal::Menu(menu));
177            Vec::new()
178        }
179        KeyCode::Home => {
180            menu.selected = 0;
181            m.modal = Some(Modal::Menu(menu));
182            Vec::new()
183        }
184        KeyCode::End => {
185            menu.selected = count.saturating_sub(1);
186            m.modal = Some(Modal::Menu(menu));
187            Vec::new()
188        }
189        KeyCode::Enter => {
190            let Some(action) = menu.items.get(menu.selected).copied() else {
191                m.modal = None;
192                return Vec::new();
193            };
194            m.modal = None;
195            if let Some(reason) = gate(m, action) {
196                return refuse(m, reason);
197            }
198            dispatch(m, action)
199        }
200        _ => {
201            m.modal = Some(Modal::Menu(menu));
202            Vec::new()
203        }
204    }
205}
206
207/// Only the nodes pane of the Cluster tab has a menu; the conditions pane explains the condition.
208pub fn cluster_has_menu(m: &Model) -> bool {
209    m.cluster.pane == Pane::Nodes
210}