1use 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
45pub 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
84pub 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
94pub 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
132pub 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
142fn 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
161pub 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
207pub fn cluster_has_menu(m: &Model) -> bool {
209 m.cluster.pane == Pane::Nodes
210}