kimun_notes/components/dialogs/
file_ops_menu.rs1use kimun_core::nfs::VaultPath;
2use ratatui::Frame;
3use ratatui::crossterm::event::KeyCode;
4use ratatui::layout::{Constraint, Direction, Layout, Rect};
5use ratatui::style::{Modifier, Style};
6use ratatui::widgets::Paragraph;
7
8use crate::components::Component;
9use crate::components::event_state::EventState;
10use crate::components::events::{AppEvent, AppTx, FileOp};
11use crate::components::panel::{ModalSpec, modal_chrome};
12use crate::settings::themes::Theme;
13
14pub struct FileOpsMenuDialog {
31 pub path: VaultPath,
33 pub path_display: String,
35}
36
37impl FileOpsMenuDialog {
38 pub fn new(path: VaultPath) -> Self {
39 let path_display = format!(" {}", path);
40 Self { path, path_display }
41 }
42
43 pub fn handle_key(
46 &mut self,
47 key: ratatui::crossterm::event::KeyEvent,
48 tx: &AppTx,
49 ) -> EventState {
50 match key.code {
51 KeyCode::Char('d') | KeyCode::Char('D') => {
52 tx.send(AppEvent::FileOp(FileOp::ShowDelete(self.path.clone())))
53 .ok();
54 EventState::Consumed
55 }
56 KeyCode::Char('r') | KeyCode::Char('R') => {
57 tx.send(AppEvent::FileOp(FileOp::ShowRename(self.path.clone())))
58 .ok();
59 EventState::Consumed
60 }
61 KeyCode::Char('m') | KeyCode::Char('M') => {
62 tx.send(AppEvent::FileOp(FileOp::ShowMove(self.path.clone())))
63 .ok();
64 EventState::Consumed
65 }
66 KeyCode::Esc => {
67 tx.send(AppEvent::CloseOverlay).ok();
68 EventState::Consumed
69 }
70 _ => EventState::Consumed, }
72 }
73}
74
75impl Component for FileOpsMenuDialog {
80 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, _focused: bool) {
81 let popup_area = super::fixed_centered_rect(46, 9, rect);
85
86 let inner = modal_chrome(
87 f,
88 popup_area,
89 theme,
90 ModalSpec {
91 title: Some(" File Operations "),
92 border: Some(Style::default().fg(theme.fg.to_ratatui())),
93 ..Default::default()
94 },
95 );
96
97 let rows = Layout::default()
107 .direction(Direction::Vertical)
108 .constraints([
109 Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
117 .split(inner);
118
119 let bg = theme.bg_panel.to_ratatui();
120 let fg = theme.fg.to_ratatui();
121 let gray = theme.gray.to_ratatui();
122 let fg_accent = theme.selection_fg.to_ratatui();
123
124 super::render_path_row(f, rows[1], &self.path_display, fg, bg);
126
127 super::render_separator(f, rows[2], gray, bg);
129
130 let action_cols = Layout::default()
134 .direction(Direction::Horizontal)
135 .constraints([
136 Constraint::Ratio(1, 3),
137 Constraint::Ratio(1, 3),
138 Constraint::Ratio(1, 3),
139 ])
140 .split(rows[3]);
141
142 let key_style = Style::default()
143 .fg(fg_accent)
144 .bg(bg)
145 .add_modifier(Modifier::BOLD);
146 let label_style = Style::default().fg(fg).bg(bg);
147
148 for (col, (key, label)) in
149 action_cols
150 .iter()
151 .zip([("[D]", " Delete"), ("[R]", " Rename"), ("[M]", " Move ")])
152 {
153 let chunks = Layout::default()
154 .direction(Direction::Horizontal)
155 .constraints([
156 Constraint::Length(1), Constraint::Length(3), Constraint::Min(1), ])
160 .split(*col);
161
162 f.render_widget(Paragraph::new(key).style(key_style), chunks[1]);
163 f.render_widget(Paragraph::new(label).style(label_style), chunks[2]);
164 }
165
166 f.render_widget(
168 Paragraph::new(" [Esc] Cancel").style(Style::default().fg(gray).bg(bg)),
169 rows[5],
170 );
171 }
172}
173
174#[cfg(test)]
179mod tests {
180 use super::*;
181
182 #[test]
183 fn esc_sends_close_dialog() {
184 use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
185 use tokio::sync::mpsc;
186
187 let rt = tokio::runtime::Runtime::new().unwrap();
188 rt.block_on(async {
189 let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
190 let mut dialog = FileOpsMenuDialog::new(VaultPath::new("notes/test.md"));
191
192 let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
193 let state = dialog.handle_key(key, &tx);
194
195 assert_eq!(state, EventState::Consumed);
196 let event = rx.try_recv().expect("expected AppEvent::CloseOverlay");
197 assert!(matches!(event, AppEvent::CloseOverlay));
198 });
199 }
200
201 #[test]
202 fn d_sends_show_delete_dialog() {
203 use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
204 use tokio::sync::mpsc;
205
206 let rt = tokio::runtime::Runtime::new().unwrap();
207 rt.block_on(async {
208 let path = VaultPath::new("notes/test.md");
209 let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
210 let mut dialog = FileOpsMenuDialog::new(path.clone());
211
212 let key = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE);
213 let state = dialog.handle_key(key, &tx);
214
215 assert_eq!(state, EventState::Consumed);
216 let event = rx
217 .try_recv()
218 .expect("expected AppEvent::FileOp(FileOp::ShowDelete)");
219 assert!(matches!(event, AppEvent::FileOp(FileOp::ShowDelete(_))));
220 });
221 }
222
223 #[test]
224 fn r_sends_show_rename_dialog() {
225 use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
226 use tokio::sync::mpsc;
227
228 let rt = tokio::runtime::Runtime::new().unwrap();
229 rt.block_on(async {
230 let path = VaultPath::new("notes/test.md");
231 let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
232 let mut dialog = FileOpsMenuDialog::new(path.clone());
233
234 let key = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE);
235 let state = dialog.handle_key(key, &tx);
236
237 assert_eq!(state, EventState::Consumed);
238 let event = rx
239 .try_recv()
240 .expect("expected AppEvent::FileOp(FileOp::ShowRename)");
241 assert!(matches!(event, AppEvent::FileOp(FileOp::ShowRename(_))));
242 });
243 }
244
245 #[test]
246 fn m_sends_show_move_dialog() {
247 use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
248 use tokio::sync::mpsc;
249
250 let rt = tokio::runtime::Runtime::new().unwrap();
251 rt.block_on(async {
252 let path = VaultPath::new("notes/test.md");
253 let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
254 let mut dialog = FileOpsMenuDialog::new(path.clone());
255
256 let key = KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE);
257 let state = dialog.handle_key(key, &tx);
258
259 assert_eq!(state, EventState::Consumed);
260 let event = rx
261 .try_recv()
262 .expect("expected AppEvent::FileOp(FileOp::ShowMove)");
263 assert!(matches!(event, AppEvent::FileOp(FileOp::ShowMove(_))));
264 });
265 }
266}