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};
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::RenameValidation { available: !exists })
95 .ok();
96 });
97
98 self.validation_task = Some(handle);
99 self.validation_state = ValidationState::Pending;
100 }
101
102 pub fn handle_key(&mut self, key: KeyEvent, tx: &AppTx) -> EventState {
109 match self.input.handle_key(&key) {
110 InputOutcome::Submit => {
111 if self.validation_state == ValidationState::Available {
112 let from = self.path.clone();
113 let parent = from.get_parent_path().0;
114 let new_name = self.input.value();
115 let new_path = if from.is_note() {
116 parent.append(&VaultPath::note_path_from(new_name))
117 } else {
118 parent.append(&VaultPath::new(new_name))
119 };
120 let vault = Arc::clone(&self.vault);
121 let tx2 = tx.clone();
122 tokio::spawn(async move {
123 let result = if from.is_note() {
124 vault.rename_note(&from, &new_path).await
125 } else {
126 vault.rename_directory(&from, &new_path).await
127 };
128 match result {
129 Ok(()) => {
130 tx2.send(AppEvent::EntryRenamed { from, to: new_path }).ok();
131 }
132 Err(e) => {
133 tx2.send(AppEvent::DialogError(e.to_string())).ok();
134 }
135 }
136 });
137 }
138 EventState::Consumed
139 }
140 InputOutcome::Cancel => {
141 tx.send(AppEvent::CloseOverlay).ok();
142 EventState::Consumed
143 }
144 InputOutcome::Changed => {
145 self.spawn_validation(tx);
146 EventState::Consumed
147 }
148 InputOutcome::Consumed => EventState::Consumed,
149 InputOutcome::NotConsumed => EventState::NotConsumed,
150 }
151 }
152}
153
154impl Component for RenameDialog {
159 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, _focused: bool) {
160 let height = if self.error.is_some() { 13 } else { 12 };
164 let popup_area = super::fixed_centered_rect(50, height, rect);
165
166 let inner = modal_chrome(
167 f,
168 popup_area,
169 theme,
170 ModalSpec {
171 title: Some(" Rename "),
172 border: Some(Style::default().fg(theme.fg.to_ratatui())),
173 ..Default::default()
174 },
175 );
176
177 let rows = Layout::default()
190 .direction(Direction::Vertical)
191 .constraints([
192 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), ])
202 .split(inner);
203
204 let bg = theme.bg_panel.to_ratatui();
205 let fg = theme.fg.to_ratatui();
206 let gray = theme.gray.to_ratatui();
207
208 super::render_path_row(f, rows[1], &self.path_display, fg, bg);
210
211 super::render_separator(f, rows[2], gray, bg);
213
214 f.render_widget(
216 Paragraph::new(" NEW NAME").style(Style::default().fg(gray).bg(bg)),
217 rows[3],
218 );
219
220 let input_chunks = Layout::default()
224 .direction(Direction::Horizontal)
225 .constraints([
226 Constraint::Min(1), Constraint::Length(3), ])
229 .split(rows[4]);
230
231 let input_block = Block::default()
232 .borders(Borders::ALL)
233 .border_style(Style::default().fg(gray))
234 .style(Style::default().bg(bg));
235 let input_inner = input_block.inner(input_chunks[0]);
236 f.render_widget(input_block, input_chunks[0]);
237 self.input
238 .render(f, input_inner, Style::default().fg(fg).bg(bg), 0, true);
239
240 let (indicator_text, indicator_style) = match self.validation_state {
242 ValidationState::Idle => (" ", Style::default()),
243 ValidationState::Pending => (" \u{231b} ", Style::default().fg(gray)),
244 ValidationState::Available => {
245 (" \u{2713} ", Style::default().fg(theme.green.to_ratatui()))
246 }
247 ValidationState::Taken => (" \u{2717} ", Style::default().fg(theme.red.to_ratatui())),
248 };
249 let indicator_rows = Layout::default()
250 .direction(Direction::Vertical)
251 .constraints([
252 Constraint::Length(1),
253 Constraint::Length(1),
254 Constraint::Length(1),
255 ])
256 .split(input_chunks[1]);
257 f.render_widget(
258 Paragraph::new(indicator_text).style(indicator_style.bg(bg)),
259 indicator_rows[1],
260 );
261
262 let (status_text, status_style) = match self.validation_state {
264 ValidationState::Idle => ("", Style::default()),
265 ValidationState::Pending => (" Checking...", Style::default().fg(gray).bg(bg)),
266 ValidationState::Available => (
267 " Available",
268 Style::default().fg(theme.green.to_ratatui()).bg(bg),
269 ),
270 ValidationState::Taken => (
271 " Already exists",
272 Style::default().fg(theme.red.to_ratatui()).bg(bg),
273 ),
274 };
275 f.render_widget(Paragraph::new(status_text).style(status_style), rows[5]);
276
277 super::render_confirm_hint(
279 f,
280 rows[7],
281 " [Enter] Rename",
282 self.validation_state == ValidationState::Available,
283 fg,
284 gray,
285 bg,
286 );
287
288 if let Some(msg) = &self.error {
290 super::render_error_row(f, rows[8], msg, theme);
291 }
292 }
293}
294
295#[cfg(test)]
300mod tests {
301 use super::*;
302 use kimun_core::VaultConfig;
303 use tokio::sync::mpsc;
304
305 #[test]
308 fn validation_state_variants_compile() {
309 let states = [
310 ValidationState::Idle,
311 ValidationState::Pending,
312 ValidationState::Available,
313 ValidationState::Taken,
314 ];
315 for state in states {
316 let _label = match state {
317 ValidationState::Idle => "idle",
318 ValidationState::Pending => "pending",
319 ValidationState::Available => "available",
320 ValidationState::Taken => "taken",
321 };
322 }
323 }
324
325 #[tokio::test]
338 #[ignore = "requires a real vault directory with kimun.sqlite"]
339 async fn new_prefills_input() {
340 use std::path::PathBuf;
341
342 let tmp = std::env::temp_dir().join("kimun_rename_test_vault");
343 std::fs::create_dir_all(&tmp).unwrap();
344
345 let vault = Arc::new(
346 NoteVault::new(VaultConfig::new(PathBuf::from(&tmp)))
347 .await
348 .expect("vault creation failed"),
349 );
350
351 let (_tx, _rx) = mpsc::unbounded_channel::<AppEvent>();
352 let path = VaultPath::new("notes/projects/kimun.md");
353 let (_, expected_filename) = path.get_parent_path();
354
355 let dialog = RenameDialog::new(path, vault);
356 assert_eq!(dialog.input.value(), expected_filename);
357 }
358
359 #[test]
362 fn esc_sends_close_dialog() {
363 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
364
365 let rt = tokio::runtime::Runtime::new().unwrap();
366 rt.block_on(async {
367 let tmp = std::env::temp_dir().join("kimun_rename_esc_test");
368 std::fs::create_dir_all(&tmp).unwrap();
369
370 let vault_result = NoteVault::new(VaultConfig::new(tmp)).await;
371 let Ok(vault) = vault_result else {
372 return;
374 };
375
376 let vault = Arc::new(vault);
377 let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
378 let mut dialog = RenameDialog::new(VaultPath::new("notes/test.md"), vault);
379
380 let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
381 let state = dialog.handle_key(key, &tx);
382
383 assert_eq!(state, EventState::Consumed);
384 let event = rx.try_recv().expect("expected AppEvent::CloseOverlay");
385 assert!(matches!(event, AppEvent::CloseOverlay));
386 });
387 }
388}