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::{Color, Style};
9use ratatui::widgets::{Block, Borders, Clear, 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::single_line_input::{InputOutcome, SingleLineInput};
17use crate::settings::themes::Theme;
18
19pub struct RenameDialog {
30 pub path: VaultPath,
32 pub vault: Arc<NoteVault>,
34 pub path_display: String,
36 pub input: SingleLineInput,
38 pub validation_state: ValidationState,
40 pub validation_task: Option<JoinHandle<()>>,
42 pub error: Option<String>,
44}
45
46impl RenameDialog {
47 pub fn new(path: VaultPath, vault: Arc<NoteVault>) -> Self {
51 let (_, filename) = path.get_parent_path();
52 let path_display = format!(" {}", path);
53 Self {
54 path,
55 vault,
56 path_display,
57 input: SingleLineInput::with_value(filename),
58 validation_state: ValidationState::Idle,
59 validation_task: None,
60 error: None,
61 }
62 }
63
64 fn spawn_validation(&mut self, tx: &AppTx) {
73 if let Some(handle) = self.validation_task.take() {
75 handle.abort();
76 }
77
78 let vault = Arc::clone(&self.vault);
79 let input = self.input.value().to_string();
80 let path = self.path.clone();
81 let tx_clone = tx.clone();
82
83 let handle = tokio::spawn(async move {
84 let parent = path.get_parent_path().0;
85 let candidate = if path.is_note() {
86 parent.append(&VaultPath::note_path_from(&input))
87 } else {
88 parent.append(&VaultPath::new(&input))
89 };
90 let exists = vault.exists(&candidate).await;
91 tx_clone
93 .send(AppEvent::RenameValidation { available: !exists })
94 .ok();
95 });
96
97 self.validation_task = Some(handle);
98 self.validation_state = ValidationState::Pending;
99 }
100
101 pub fn handle_key(&mut self, key: KeyEvent, tx: &AppTx) -> EventState {
108 match self.input.handle_key(&key) {
109 InputOutcome::Submit => {
110 if self.validation_state == ValidationState::Available {
111 let from = self.path.clone();
112 let parent = from.get_parent_path().0;
113 let new_name = self.input.value();
114 let new_path = if from.is_note() {
115 parent.append(&VaultPath::note_path_from(new_name))
116 } else {
117 parent.append(&VaultPath::new(new_name))
118 };
119 let vault = Arc::clone(&self.vault);
120 let tx2 = tx.clone();
121 tokio::spawn(async move {
122 let result = if from.is_note() {
123 vault.rename_note(&from, &new_path).await
124 } else {
125 vault.rename_directory(&from, &new_path).await
126 };
127 match result {
128 Ok(()) => {
129 tx2.send(AppEvent::EntryRenamed { from, to: new_path }).ok();
130 }
131 Err(e) => {
132 tx2.send(AppEvent::DialogError(e.to_string())).ok();
133 }
134 }
135 });
136 }
137 EventState::Consumed
138 }
139 InputOutcome::Cancel => {
140 tx.send(AppEvent::CloseDialog).ok();
141 EventState::Consumed
142 }
143 InputOutcome::Changed => {
144 self.spawn_validation(tx);
145 EventState::Consumed
146 }
147 InputOutcome::Consumed => EventState::Consumed,
148 InputOutcome::NotConsumed => EventState::NotConsumed,
149 }
150 }
151}
152
153impl Component for RenameDialog {
158 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, _focused: bool) {
159 let height = if self.error.is_some() { 13 } else { 12 };
163 let popup_area = super::fixed_centered_rect(50, height, rect);
164
165 f.render_widget(Clear, popup_area);
166
167 let outer_block = Block::default()
168 .title(" Rename ")
169 .borders(Borders::ALL)
170 .border_style(Style::default().fg(theme.fg.to_ratatui()))
171 .style(theme.panel_style());
172 let inner = outer_block.inner(popup_area);
173 f.render_widget(outer_block, popup_area);
174
175 let rows = Layout::default()
188 .direction(Direction::Vertical)
189 .constraints([
190 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), ])
200 .split(inner);
201
202 let bg = theme.bg_panel.to_ratatui();
203 let fg = theme.fg.to_ratatui();
204 let fg_muted = theme.fg_muted.to_ratatui();
205
206 super::render_path_row(f, rows[1], &self.path_display, fg, bg);
208
209 super::render_separator(f, rows[2], fg_muted, bg);
211
212 f.render_widget(
214 Paragraph::new(" NEW NAME").style(Style::default().fg(fg_muted).bg(bg)),
215 rows[3],
216 );
217
218 let input_chunks = Layout::default()
222 .direction(Direction::Horizontal)
223 .constraints([
224 Constraint::Min(1), Constraint::Length(3), ])
227 .split(rows[4]);
228
229 let input_block = Block::default()
230 .borders(Borders::ALL)
231 .border_style(Style::default().fg(fg_muted))
232 .style(Style::default().bg(bg));
233 let input_inner = input_block.inner(input_chunks[0]);
234 f.render_widget(input_block, input_chunks[0]);
235 self.input
236 .render(f, input_inner, Style::default().fg(fg).bg(bg), 0, true);
237
238 let (indicator_text, indicator_style) = match self.validation_state {
240 ValidationState::Idle => (" ", Style::default()),
241 ValidationState::Pending => (" \u{231b} ", Style::default().fg(fg_muted)),
242 ValidationState::Available => (" \u{2713} ", Style::default().fg(Color::Green)),
243 ValidationState::Taken => (" \u{2717} ", Style::default().fg(Color::Red)),
244 };
245 let indicator_rows = Layout::default()
246 .direction(Direction::Vertical)
247 .constraints([
248 Constraint::Length(1),
249 Constraint::Length(1),
250 Constraint::Length(1),
251 ])
252 .split(input_chunks[1]);
253 f.render_widget(
254 Paragraph::new(indicator_text).style(indicator_style.bg(bg)),
255 indicator_rows[1],
256 );
257
258 let (status_text, status_style) = match self.validation_state {
260 ValidationState::Idle => ("", Style::default()),
261 ValidationState::Pending => (" Checking...", Style::default().fg(fg_muted).bg(bg)),
262 ValidationState::Available => (" Available", Style::default().fg(Color::Green).bg(bg)),
263 ValidationState::Taken => (" Already exists", Style::default().fg(Color::Red).bg(bg)),
264 };
265 f.render_widget(Paragraph::new(status_text).style(status_style), rows[5]);
266
267 super::render_confirm_hint(
269 f,
270 rows[7],
271 " [Enter] Rename",
272 self.validation_state == ValidationState::Available,
273 fg,
274 fg_muted,
275 bg,
276 );
277
278 if let Some(msg) = &self.error {
280 super::render_error_row(f, rows[8], msg, bg);
281 }
282 }
283}
284
285#[cfg(test)]
290mod tests {
291 use super::*;
292 use kimun_core::VaultConfig;
293 use tokio::sync::mpsc;
294
295 #[test]
298 fn validation_state_variants_compile() {
299 let states = [
300 ValidationState::Idle,
301 ValidationState::Pending,
302 ValidationState::Available,
303 ValidationState::Taken,
304 ];
305 for state in states {
306 let _label = match state {
307 ValidationState::Idle => "idle",
308 ValidationState::Pending => "pending",
309 ValidationState::Available => "available",
310 ValidationState::Taken => "taken",
311 };
312 }
313 }
314
315 #[tokio::test]
328 #[ignore = "requires a real vault directory with kimun.sqlite"]
329 async fn new_prefills_input() {
330 use std::path::PathBuf;
331
332 let tmp = std::env::temp_dir().join("kimun_rename_test_vault");
333 std::fs::create_dir_all(&tmp).unwrap();
334
335 let vault = Arc::new(
336 NoteVault::new(VaultConfig::new(PathBuf::from(&tmp)))
337 .await
338 .expect("vault creation failed"),
339 );
340
341 let (_tx, _rx) = mpsc::unbounded_channel::<AppEvent>();
342 let path = VaultPath::new("notes/projects/kimun.md");
343 let (_, expected_filename) = path.get_parent_path();
344
345 let dialog = RenameDialog::new(path, vault);
346 assert_eq!(dialog.input.value(), expected_filename);
347 }
348
349 #[test]
352 fn esc_sends_close_dialog() {
353 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
354
355 let rt = tokio::runtime::Runtime::new().unwrap();
356 rt.block_on(async {
357 let tmp = std::env::temp_dir().join("kimun_rename_esc_test");
358 std::fs::create_dir_all(&tmp).unwrap();
359
360 let vault_result = NoteVault::new(VaultConfig::new(tmp)).await;
361 let Ok(vault) = vault_result else {
362 return;
364 };
365
366 let vault = Arc::new(vault);
367 let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
368 let mut dialog = RenameDialog::new(VaultPath::new("notes/test.md"), vault);
369
370 let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
371 let state = dialog.handle_key(key, &tx);
372
373 assert_eq!(state, EventState::Consumed);
374 let event = rx.try_recv().expect("expected AppEvent::CloseDialog");
375 assert!(matches!(event, AppEvent::CloseDialog));
376 });
377 }
378}