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::{Block, Borders, Clear, Paragraph};
7
8use crate::components::Component;
9use crate::components::event_state::EventState;
10use crate::components::events::{AppEvent, AppTx};
11use crate::settings::themes::Theme;
12
13pub struct FileOpsMenuDialog {
30 pub path: VaultPath,
32 pub path_display: String,
34}
35
36impl FileOpsMenuDialog {
37 pub fn new(path: VaultPath) -> Self {
38 let path_display = format!(" {}", path);
39 Self { path, path_display }
40 }
41
42 pub fn handle_key(
45 &mut self,
46 key: ratatui::crossterm::event::KeyEvent,
47 tx: &AppTx,
48 ) -> EventState {
49 match key.code {
50 KeyCode::Char('d') | KeyCode::Char('D') => {
51 tx.send(AppEvent::ShowDeleteDialog(self.path.clone())).ok();
52 EventState::Consumed
53 }
54 KeyCode::Char('r') | KeyCode::Char('R') => {
55 tx.send(AppEvent::ShowRenameDialog(self.path.clone())).ok();
56 EventState::Consumed
57 }
58 KeyCode::Char('m') | KeyCode::Char('M') => {
59 tx.send(AppEvent::ShowMoveDialog(self.path.clone())).ok();
60 EventState::Consumed
61 }
62 KeyCode::Esc => {
63 tx.send(AppEvent::CloseDialog).ok();
64 EventState::Consumed
65 }
66 _ => EventState::Consumed, }
68 }
69}
70
71impl Component for FileOpsMenuDialog {
76 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, _focused: bool) {
77 let popup_area = super::fixed_centered_rect(46, 9, rect);
81
82 f.render_widget(Clear, popup_area);
83
84 let outer_block = Block::default()
85 .title(" File Operations ")
86 .borders(Borders::ALL)
87 .border_style(Style::default().fg(theme.fg.to_ratatui()))
88 .style(theme.panel_style());
89 let inner = outer_block.inner(popup_area);
90 f.render_widget(outer_block, popup_area);
91
92 let rows = Layout::default()
102 .direction(Direction::Vertical)
103 .constraints([
104 Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
112 .split(inner);
113
114 let bg = theme.bg_panel.to_ratatui();
115 let fg = theme.fg.to_ratatui();
116 let fg_muted = theme.fg_muted.to_ratatui();
117 let fg_accent = theme.fg_selected.to_ratatui();
118
119 super::render_path_row(f, rows[1], &self.path_display, fg, bg);
121
122 super::render_separator(f, rows[2], fg_muted, bg);
124
125 let action_cols = Layout::default()
129 .direction(Direction::Horizontal)
130 .constraints([
131 Constraint::Ratio(1, 3),
132 Constraint::Ratio(1, 3),
133 Constraint::Ratio(1, 3),
134 ])
135 .split(rows[3]);
136
137 let key_style = Style::default()
138 .fg(fg_accent)
139 .bg(bg)
140 .add_modifier(Modifier::BOLD);
141 let label_style = Style::default().fg(fg).bg(bg);
142
143 for (col, (key, label)) in
144 action_cols
145 .iter()
146 .zip([("[D]", " Delete"), ("[R]", " Rename"), ("[M]", " Move ")])
147 {
148 let chunks = Layout::default()
149 .direction(Direction::Horizontal)
150 .constraints([
151 Constraint::Length(1), Constraint::Length(3), Constraint::Min(1), ])
155 .split(*col);
156
157 f.render_widget(Paragraph::new(key).style(key_style), chunks[1]);
158 f.render_widget(Paragraph::new(label).style(label_style), chunks[2]);
159 }
160
161 f.render_widget(
163 Paragraph::new(" [Esc] Cancel").style(Style::default().fg(fg_muted).bg(bg)),
164 rows[5],
165 );
166 }
167}
168
169#[cfg(test)]
174mod tests {
175 use super::*;
176
177 #[test]
178 fn esc_sends_close_dialog() {
179 use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
180 use tokio::sync::mpsc;
181
182 let rt = tokio::runtime::Runtime::new().unwrap();
183 rt.block_on(async {
184 let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
185 let mut dialog = FileOpsMenuDialog::new(VaultPath::new("notes/test.md"));
186
187 let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
188 let state = dialog.handle_key(key, &tx);
189
190 assert_eq!(state, EventState::Consumed);
191 let event = rx.try_recv().expect("expected AppEvent::CloseDialog");
192 assert!(matches!(event, AppEvent::CloseDialog));
193 });
194 }
195
196 #[test]
197 fn d_sends_show_delete_dialog() {
198 use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
199 use tokio::sync::mpsc;
200
201 let rt = tokio::runtime::Runtime::new().unwrap();
202 rt.block_on(async {
203 let path = VaultPath::new("notes/test.md");
204 let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
205 let mut dialog = FileOpsMenuDialog::new(path.clone());
206
207 let key = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE);
208 let state = dialog.handle_key(key, &tx);
209
210 assert_eq!(state, EventState::Consumed);
211 let event = rx.try_recv().expect("expected AppEvent::ShowDeleteDialog");
212 assert!(matches!(event, AppEvent::ShowDeleteDialog(_)));
213 });
214 }
215
216 #[test]
217 fn r_sends_show_rename_dialog() {
218 use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
219 use tokio::sync::mpsc;
220
221 let rt = tokio::runtime::Runtime::new().unwrap();
222 rt.block_on(async {
223 let path = VaultPath::new("notes/test.md");
224 let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
225 let mut dialog = FileOpsMenuDialog::new(path.clone());
226
227 let key = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE);
228 let state = dialog.handle_key(key, &tx);
229
230 assert_eq!(state, EventState::Consumed);
231 let event = rx.try_recv().expect("expected AppEvent::ShowRenameDialog");
232 assert!(matches!(event, AppEvent::ShowRenameDialog(_)));
233 });
234 }
235
236 #[test]
237 fn m_sends_show_move_dialog() {
238 use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
239 use tokio::sync::mpsc;
240
241 let rt = tokio::runtime::Runtime::new().unwrap();
242 rt.block_on(async {
243 let path = VaultPath::new("notes/test.md");
244 let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
245 let mut dialog = FileOpsMenuDialog::new(path.clone());
246
247 let key = KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE);
248 let state = dialog.handle_key(key, &tx);
249
250 assert_eq!(state, EventState::Consumed);
251 let event = rx.try_recv().expect("expected AppEvent::ShowMoveDialog");
252 assert!(matches!(event, AppEvent::ShowMoveDialog(_)));
253 });
254 }
255}