kimun_notes/components/dialogs/
rename_dialog.rs1use std::sync::Arc;
2
3use kimun_core::NoteVault;
4use kimun_core::nfs::VaultPath;
5use ratatui::Frame;
6use ratatui::crossterm::event::KeyEvent;
7use ratatui::layout::{Constraint, Direction, Layout, Rect};
8use ratatui::style::Style;
9use ratatui::widgets::{Block, Borders, Paragraph};
10use tokio::task::JoinHandle;
11
12use crate::components::Component;
13use crate::components::dialogs::ValidationState;
14use crate::components::event_state::EventState;
15use crate::components::events::{AppEvent, AppTx, FileOp, OverlayData};
16use crate::components::panel::{ModalSpec, modal_chrome};
17use crate::components::single_line_input::{InputOutcome, SingleLineInput};
18use crate::settings::themes::Theme;
19
20pub struct RenameDialog {
31 pub path: VaultPath,
33 pub vault: Arc<NoteVault>,
35 pub path_display: String,
37 pub input: SingleLineInput,
39 pub validation_state: ValidationState,
41 pub validation_task: Option<JoinHandle<()>>,
43 pub error: Option<String>,
45}
46
47impl RenameDialog {
48 pub fn new(path: VaultPath, vault: Arc<NoteVault>) -> Self {
52 let (_, filename) = path.get_parent_path();
53 let path_display = format!(" {}", path);
54 Self {
55 path,
56 vault,
57 path_display,
58 input: SingleLineInput::with_value(filename),
59 validation_state: ValidationState::Idle,
60 validation_task: None,
61 error: None,
62 }
63 }
64
65 fn spawn_validation(&mut self, tx: &AppTx) {
74 if let Some(handle) = self.validation_task.take() {
76 handle.abort();
77 }
78
79 let vault = Arc::clone(&self.vault);
80 let input = self.input.value().to_string();
81 let path = self.path.clone();
82 let tx_clone = tx.clone();
83
84 let handle = tokio::spawn(async move {
85 let parent = path.get_parent_path().0;
86 let candidate = if path.is_note() {
87 parent.append(&VaultPath::note_path_from(&input))
88 } else {
89 parent.append(&VaultPath::new(&input))
90 };
91 let exists = vault.exists(&candidate).await;
92 tx_clone
94 .send(AppEvent::OverlayData(OverlayData::RenameValidation {
95 available: !exists,
96 }))
97 .ok();
98 });
99
100 self.validation_task = Some(handle);
101 self.validation_state = ValidationState::Pending;
102 }
103
104 pub fn handle_key(&mut self, key: KeyEvent, tx: &AppTx) -> EventState {
111 match self.input.handle_key(&key) {
112 InputOutcome::Submit => {
113 if self.validation_state == ValidationState::Available {
114 let from = self.path.clone();
115 let parent = from.get_parent_path().0;
116 let new_name = self.input.value();
117 let new_path = if from.is_note() {
118 parent.append(&VaultPath::note_path_from(new_name))
119 } else {
120 parent.append(&VaultPath::new(new_name))
121 };
122 let vault = Arc::clone(&self.vault);
123 let tx2 = tx.clone();
124 tokio::spawn(async move {
125 let result = vault.rename_entry(&from, &new_path).await;
128 match result {
129 Ok(()) => {
130 tx2.send(AppEvent::FileOp(FileOp::Renamed { from, to: new_path }))
131 .ok();
132 }
133 Err(e) => {
134 tx2.send(AppEvent::OverlayData(OverlayData::Error(e.to_string())))
135 .ok();
136 }
137 }
138 });
139 }
140 EventState::Consumed
141 }
142 InputOutcome::Cancel => {
143 tx.send(AppEvent::CloseOverlay).ok();
144 EventState::Consumed
145 }
146 InputOutcome::Changed => {
147 self.spawn_validation(tx);
148 EventState::Consumed
149 }
150 InputOutcome::Consumed => EventState::Consumed,
151 InputOutcome::NotConsumed => EventState::NotConsumed,
152 }
153 }
154}
155
156impl Component for RenameDialog {
161 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, _focused: bool) {
162 let height = if self.error.is_some() { 13 } else { 12 };
166 let popup_area = super::fixed_centered_rect(50, height, rect);
167
168 let inner = modal_chrome(
169 f,
170 popup_area,
171 theme,
172 ModalSpec {
173 title: Some(" Rename "),
174 border: Some(Style::default().fg(theme.fg.to_ratatui())),
175 ..Default::default()
176 },
177 );
178
179 let rows = Layout::default()
192 .direction(Direction::Vertical)
193 .constraints([
194 Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(3), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
204 .split(inner);
205
206 let bg = theme.bg_panel.to_ratatui();
207 let fg = theme.fg.to_ratatui();
208 let gray = theme.gray.to_ratatui();
209
210 super::render_path_row(f, rows[1], &self.path_display, fg, bg);
212
213 super::render_separator(f, rows[2], gray, bg);
215
216 f.render_widget(
218 Paragraph::new(" NEW NAME").style(Style::default().fg(gray).bg(bg)),
219 rows[3],
220 );
221
222 let input_chunks = Layout::default()
226 .direction(Direction::Horizontal)
227 .constraints([
228 Constraint::Min(1), Constraint::Length(3), ])
231 .split(rows[4]);
232
233 let input_block = Block::default()
234 .borders(Borders::ALL)
235 .border_style(Style::default().fg(gray))
236 .style(Style::default().bg(bg));
237 let input_inner = input_block.inner(input_chunks[0]);
238 f.render_widget(input_block, input_chunks[0]);
239 self.input
240 .render(f, input_inner, Style::default().fg(fg).bg(bg), 0, true);
241
242 let (indicator_text, indicator_style) = match self.validation_state {
244 ValidationState::Idle => (" ", Style::default()),
245 ValidationState::Pending => (" \u{231b} ", Style::default().fg(gray)),
246 ValidationState::Available => {
247 (" \u{2713} ", Style::default().fg(theme.green.to_ratatui()))
248 }
249 ValidationState::Taken => (" \u{2717} ", Style::default().fg(theme.red.to_ratatui())),
250 };
251 let indicator_rows = Layout::default()
252 .direction(Direction::Vertical)
253 .constraints([
254 Constraint::Length(1),
255 Constraint::Length(1),
256 Constraint::Length(1),
257 ])
258 .split(input_chunks[1]);
259 f.render_widget(
260 Paragraph::new(indicator_text).style(indicator_style.bg(bg)),
261 indicator_rows[1],
262 );
263
264 let (status_text, status_style) = match self.validation_state {
266 ValidationState::Idle => ("", Style::default()),
267 ValidationState::Pending => (" Checking...", Style::default().fg(gray).bg(bg)),
268 ValidationState::Available => (
269 " Available",
270 Style::default().fg(theme.green.to_ratatui()).bg(bg),
271 ),
272 ValidationState::Taken => (
273 " Already exists",
274 Style::default().fg(theme.red.to_ratatui()).bg(bg),
275 ),
276 };
277 f.render_widget(Paragraph::new(status_text).style(status_style), rows[5]);
278
279 super::render_confirm_hint(
281 f,
282 rows[7],
283 " [Enter] Rename",
284 self.validation_state == ValidationState::Available,
285 fg,
286 gray,
287 bg,
288 );
289
290 if let Some(msg) = &self.error {
292 super::render_error_row(f, rows[8], msg, theme);
293 }
294 }
295}
296
297#[cfg(test)]
302mod tests {
303 use super::*;
304 use kimun_core::VaultConfig;
305 use tokio::sync::mpsc;
306
307 #[test]
310 fn validation_state_variants_compile() {
311 let states = [
312 ValidationState::Idle,
313 ValidationState::Pending,
314 ValidationState::Available,
315 ValidationState::Taken,
316 ];
317 for state in states {
318 let _label = match state {
319 ValidationState::Idle => "idle",
320 ValidationState::Pending => "pending",
321 ValidationState::Available => "available",
322 ValidationState::Taken => "taken",
323 };
324 }
325 }
326
327 #[tokio::test]
340 #[ignore = "requires a real vault directory with kimun.sqlite"]
341 async fn new_prefills_input() {
342 use std::path::PathBuf;
343
344 let tmp = std::env::temp_dir().join("kimun_rename_test_vault");
345 std::fs::create_dir_all(&tmp).unwrap();
346
347 let vault = Arc::new(
348 NoteVault::new(VaultConfig::new(crate::test_support::sys(PathBuf::from(
349 &tmp,
350 ))))
351 .await
352 .expect("vault creation failed"),
353 );
354
355 let (_tx, _rx) = mpsc::unbounded_channel::<AppEvent>();
356 let path = VaultPath::new("notes/projects/kimun.md");
357 let (_, expected_filename) = path.get_parent_path();
358
359 let dialog = RenameDialog::new(path, vault);
360 assert_eq!(dialog.input.value(), expected_filename);
361 }
362
363 #[test]
366 fn esc_sends_close_dialog() {
367 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
368
369 let rt = tokio::runtime::Runtime::new().unwrap();
370 rt.block_on(async {
371 let tmp = std::env::temp_dir().join("kimun_rename_esc_test");
372 std::fs::create_dir_all(&tmp).unwrap();
373
374 let vault_result =
375 NoteVault::new(VaultConfig::new(crate::test_support::sys(tmp))).await;
376 let Ok(vault) = vault_result else {
377 return;
379 };
380
381 let vault = Arc::new(vault);
382 let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
383 let mut dialog = RenameDialog::new(VaultPath::new("notes/test.md"), vault);
384
385 let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
386 let state = dialog.handle_key(key, &tx);
387
388 assert_eq!(state, EventState::Consumed);
389 let event = rx.try_recv().expect("expected AppEvent::CloseOverlay");
390 assert!(matches!(event, AppEvent::CloseOverlay));
391 });
392 }
393}