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};
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::ShowDeleteDialog(self.path.clone())).ok();
53 EventState::Consumed
54 }
55 KeyCode::Char('r') | KeyCode::Char('R') => {
56 tx.send(AppEvent::ShowRenameDialog(self.path.clone())).ok();
57 EventState::Consumed
58 }
59 KeyCode::Char('m') | KeyCode::Char('M') => {
60 tx.send(AppEvent::ShowMoveDialog(self.path.clone())).ok();
61 EventState::Consumed
62 }
63 KeyCode::Esc => {
64 tx.send(AppEvent::CloseOverlay).ok();
65 EventState::Consumed
66 }
67 _ => EventState::Consumed, }
69 }
70}
71
72impl Component for FileOpsMenuDialog {
77 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, _focused: bool) {
78 let popup_area = super::fixed_centered_rect(46, 9, rect);
82
83 let inner = modal_chrome(
84 f,
85 popup_area,
86 theme,
87 ModalSpec {
88 title: Some(" File Operations "),
89 border: Some(Style::default().fg(theme.fg.to_ratatui())),
90 ..Default::default()
91 },
92 );
93
94 let rows = Layout::default()
104 .direction(Direction::Vertical)
105 .constraints([
106 Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
114 .split(inner);
115
116 let bg = theme.bg_panel.to_ratatui();
117 let fg = theme.fg.to_ratatui();
118 let gray = theme.gray.to_ratatui();
119 let fg_accent = theme.selection_fg.to_ratatui();
120
121 super::render_path_row(f, rows[1], &self.path_display, fg, bg);
123
124 super::render_separator(f, rows[2], gray, bg);
126
127 let action_cols = Layout::default()
131 .direction(Direction::Horizontal)
132 .constraints([
133 Constraint::Ratio(1, 3),
134 Constraint::Ratio(1, 3),
135 Constraint::Ratio(1, 3),
136 ])
137 .split(rows[3]);
138
139 let key_style = Style::default()
140 .fg(fg_accent)
141 .bg(bg)
142 .add_modifier(Modifier::BOLD);
143 let label_style = Style::default().fg(fg).bg(bg);
144
145 for (col, (key, label)) in
146 action_cols
147 .iter()
148 .zip([("[D]", " Delete"), ("[R]", " Rename"), ("[M]", " Move ")])
149 {
150 let chunks = Layout::default()
151 .direction(Direction::Horizontal)
152 .constraints([
153 Constraint::Length(1), Constraint::Length(3), Constraint::Min(1), ])
157 .split(*col);
158
159 f.render_widget(Paragraph::new(key).style(key_style), chunks[1]);
160 f.render_widget(Paragraph::new(label).style(label_style), chunks[2]);
161 }
162
163 f.render_widget(
165 Paragraph::new(" [Esc] Cancel").style(Style::default().fg(gray).bg(bg)),
166 rows[5],
167 );
168 }
169}
170
171#[cfg(test)]
176mod tests {
177 use super::*;
178
179 #[test]
180 fn esc_sends_close_dialog() {
181 use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
182 use tokio::sync::mpsc;
183
184 let rt = tokio::runtime::Runtime::new().unwrap();
185 rt.block_on(async {
186 let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
187 let mut dialog = FileOpsMenuDialog::new(VaultPath::new("notes/test.md"));
188
189 let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
190 let state = dialog.handle_key(key, &tx);
191
192 assert_eq!(state, EventState::Consumed);
193 let event = rx.try_recv().expect("expected AppEvent::CloseOverlay");
194 assert!(matches!(event, AppEvent::CloseOverlay));
195 });
196 }
197
198 #[test]
199 fn d_sends_show_delete_dialog() {
200 use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
201 use tokio::sync::mpsc;
202
203 let rt = tokio::runtime::Runtime::new().unwrap();
204 rt.block_on(async {
205 let path = VaultPath::new("notes/test.md");
206 let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
207 let mut dialog = FileOpsMenuDialog::new(path.clone());
208
209 let key = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE);
210 let state = dialog.handle_key(key, &tx);
211
212 assert_eq!(state, EventState::Consumed);
213 let event = rx.try_recv().expect("expected AppEvent::ShowDeleteDialog");
214 assert!(matches!(event, AppEvent::ShowDeleteDialog(_)));
215 });
216 }
217
218 #[test]
219 fn r_sends_show_rename_dialog() {
220 use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
221 use tokio::sync::mpsc;
222
223 let rt = tokio::runtime::Runtime::new().unwrap();
224 rt.block_on(async {
225 let path = VaultPath::new("notes/test.md");
226 let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
227 let mut dialog = FileOpsMenuDialog::new(path.clone());
228
229 let key = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE);
230 let state = dialog.handle_key(key, &tx);
231
232 assert_eq!(state, EventState::Consumed);
233 let event = rx.try_recv().expect("expected AppEvent::ShowRenameDialog");
234 assert!(matches!(event, AppEvent::ShowRenameDialog(_)));
235 });
236 }
237
238 #[test]
239 fn m_sends_show_move_dialog() {
240 use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
241 use tokio::sync::mpsc;
242
243 let rt = tokio::runtime::Runtime::new().unwrap();
244 rt.block_on(async {
245 let path = VaultPath::new("notes/test.md");
246 let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
247 let mut dialog = FileOpsMenuDialog::new(path.clone());
248
249 let key = KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE);
250 let state = dialog.handle_key(key, &tx);
251
252 assert_eq!(state, EventState::Consumed);
253 let event = rx.try_recv().expect("expected AppEvent::ShowMoveDialog");
254 assert!(matches!(event, AppEvent::ShowMoveDialog(_)));
255 });
256 }
257}