Skip to main content

kimun_notes/components/dialogs/
file_ops_menu.rs

1use 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
14// ---------------------------------------------------------------------------
15// FileOpsMenuDialog
16// ---------------------------------------------------------------------------
17
18/// Small menu dialog that lets the user pick a file operation.
19///
20/// ```text
21/// ┌─ File Operations ────────────────────────────┐
22/// │                                              │
23/// │  notes/projects/kimun.md                     │
24/// │                                              │
25/// │  [D] Delete   [R] Rename   [M] Move          │
26/// │                                              │
27/// │  [Esc] Cancel                                │
28/// └──────────────────────────────────────────────┘
29/// ```
30pub struct FileOpsMenuDialog {
31    /// The vault entry this menu was opened for.
32    pub path: VaultPath,
33    /// Pre-computed `"  {path}"` for zero-allocation rendering.
34    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    /// Handle a raw key event. Returns `Consumed` for all recognised keys so
44    /// the event never leaks to the underlying panel.
45    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, // swallow unknown keys while menu is open
71        }
72    }
73}
74
75// ---------------------------------------------------------------------------
76// Component trait
77// ---------------------------------------------------------------------------
78
79impl Component for FileOpsMenuDialog {
80    fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, _focused: bool) {
81        // Fixed size: 46 wide × 9 tall
82        // Border (2) + spacer + path + separator + actions + spacer + hint + spacer = 9 inner rows → 11 total
83        // But keep it tight: border(2) + 7 inner rows = 9
84        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        // ── Layout ────────────────────────────────────────────────────────────
98        // Row 0: spacer
99        // Row 1: path display
100        // Row 2: separator (horizontal line)
101        // Row 3: action row  [D] Delete  [R] Rename  [M] Move
102        // Row 4: spacer
103        // Row 5: hint row    [Esc] Cancel
104        // Row 6: spacer
105
106        let rows = Layout::default()
107            .direction(Direction::Vertical)
108            .constraints([
109                Constraint::Length(1), // 0: spacer
110                Constraint::Length(1), // 1: path
111                Constraint::Length(1), // 2: separator
112                Constraint::Length(1), // 3: actions
113                Constraint::Length(1), // 4: spacer
114                Constraint::Length(1), // 5: hint
115                Constraint::Min(0),    // 6: remainder
116            ])
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        // Row 1: path
125        super::render_path_row(f, rows[1], &self.path_display, fg, bg);
126
127        // Row 2: separator
128        super::render_separator(f, rows[2], gray, bg);
129
130        // Row 3: action shortcuts — key letter highlighted, description muted
131        //
132        // Split into three equal columns.
133        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), // left padding
157                    Constraint::Length(3), // "[D]"
158                    Constraint::Min(1),    // " Delete"
159                ])
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        // Row 5: hint
167        f.render_widget(
168            Paragraph::new("  [Esc] Cancel").style(Style::default().fg(gray).bg(bg)),
169            rows[5],
170        );
171    }
172}
173
174// ---------------------------------------------------------------------------
175// Tests
176// ---------------------------------------------------------------------------
177
178#[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}